release v0.1.0

This commit is contained in:
Kinneyzhang 2026-10-02 09:26:45 +08:00
commit 3094f5b1dc
27 changed files with 4805 additions and 0 deletions

246
MANIFEST.json Normal file
View File

@ -0,0 +1,246 @@
{
"manifest_version": 1,
"name": "skill-interface",
"summary": "所有技能的统一切入点:技能目录、用法转发、PATH 入口注册与契约检查",
"tier": 1,
"status": "stable",
"version": "2e555b1",
"source": {
"path": "skills/skill-interface",
"commit": "2e555b1",
"describe": "2e555b1",
"dirty": false,
"packed_at": "2026-10-02T01:26:44.706Z"
},
"platforms": [
"all"
],
"entry": {
"path": "scripts/pi-skill.mjs"
},
"commands": [
"list",
"help",
"stats",
"impact",
"doctor",
"pack",
"audit"
],
"errors": [
"usage",
"deps",
"conflict"
],
"aliases": [
"pi-skill"
],
"admin": [
"scripts/skill-registry.mjs",
"scripts/skill-deps.mjs",
"scripts/skill-config.mjs",
"scripts/skill-pack.mjs",
"scripts/skill-contract.mjs",
"scripts/skill-invocation-log.mjs",
"scripts/skillcheck.mjs",
"scripts/skill-entry-install.mjs",
"scripts/install.sh",
"scripts/skill-audit.mjs",
"scripts/skill-retire.mjs",
"scripts/skill-state.mjs",
"scripts/skill-release.mjs"
],
"depends_on": [],
"requires": [
{
"kind": "binary",
"name": "tar",
"install": "macOS/Windows 10+ 自带",
"check": "tar --version",
"note": "pi-skill pack 生成 .tar.gz 用;没有也能出目录包"
}
],
"config": [],
"external_imports": [
{
"from": "scripts/skill-registry.mjs",
"import": "../../../runtime/platform-resources.mjs",
"target": "../../runtime/platform-resources.mjs"
}
],
"contract": {
"version": 1,
"skill": "skill-interface",
"tier": 1,
"platforms": [
"all"
],
"commands": {
"audit": {
"destructive": false
},
"doctor": {
"destructive": false
},
"help": {
"destructive": false
},
"impact": {
"destructive": false
},
"list": {
"destructive": false
},
"pack": {
"destructive": false
},
"stats": {
"destructive": false
}
},
"errors": [
"conflict",
"deps",
"usage"
],
"aliases": [
"pi-skill"
],
"adminAliases": [
"skill-audit",
"skill-contract",
"skill-entry-install",
"skill-pack",
"skill-release",
"skill-retire",
"skillcheck"
]
},
"files": [
{
"path": "REFERENCE.md",
"bytes": 50414,
"sha256": "27b08f88bdb293eb"
},
{
"path": "SKILL.md",
"bytes": 5851,
"sha256": "3159328706c5fb9c"
},
{
"path": "VERSION",
"bytes": 6,
"sha256": "e9dd8507f4bf0c6f"
},
{
"path": "contract.lock.json",
"bytes": 719,
"sha256": "e529a873f78d4888"
},
{
"path": "interface.json",
"bytes": 4069,
"sha256": "0ac42eafd5a74e56"
},
{
"path": "references/user-guide.md",
"bytes": 19289,
"sha256": "8720a1ba65972cd1"
},
{
"path": "scripts/install.sh",
"bytes": 984,
"sha256": "f0577c8c0205a280"
},
{
"path": "scripts/lib/args.mjs",
"bytes": 3816,
"sha256": "96fbd49359e3b5f2"
},
{
"path": "scripts/lib/dupguard.mjs",
"bytes": 3307,
"sha256": "130695aa5cb95925"
},
{
"path": "scripts/lib/notify.mjs",
"bytes": 1606,
"sha256": "9693dc4f68a75b6f"
},
{
"path": "scripts/lib/render.mjs",
"bytes": 4593,
"sha256": "5d4d1db8fa217aeb"
},
{
"path": "scripts/pi-skill.mjs",
"bytes": 22326,
"sha256": "48a3e8637e97a0d2"
},
{
"path": "scripts/skill-audit.mjs",
"bytes": 10686,
"sha256": "4a4d873bbee03054"
},
{
"path": "scripts/skill-config.mjs",
"bytes": 11074,
"sha256": "623a61fe07f22212"
},
{
"path": "scripts/skill-contract.mjs",
"bytes": 12046,
"sha256": "542642c2a4d93552"
},
{
"path": "scripts/skill-deps.mjs",
"bytes": 8748,
"sha256": "78fc5061d092234b"
},
{
"path": "scripts/skill-entry-install.mjs",
"bytes": 7034,
"sha256": "4d6e834fc926736a"
},
{
"path": "scripts/skill-invocation-log.mjs",
"bytes": 8352,
"sha256": "102eab9563ba8a0e"
},
{
"path": "scripts/skill-pack.mjs",
"bytes": 14646,
"sha256": "05f0176c3a0b1c5a"
},
{
"path": "scripts/skill-registry.mjs",
"bytes": 22758,
"sha256": "0cfc283c644298d2"
},
{
"path": "scripts/skill-release.mjs",
"bytes": 16585,
"sha256": "981002a29824233e"
},
{
"path": "scripts/skill-retire.mjs",
"bytes": 9445,
"sha256": "93f961626da433ef"
},
{
"path": "scripts/skill-state.mjs",
"bytes": 5163,
"sha256": "54356d18d1422294"
},
{
"path": "scripts/skillcheck.mjs",
"bytes": 36530,
"sha256": "b402cb25a2d219f6"
}
],
"leak_scan": {
"errors": 0,
"warnings": 0,
"findings": []
}
}

14
README.md Normal file
View File

@ -0,0 +1,14 @@
# skill-interface
所有技能的统一切入点:技能目录、用法转发、PATH 入口注册与契约检查
以 Pi package(技能形态)发布。
技能本体在 `skills/skill-interface/`,用法见其 `SKILL.md` 与 `REFERENCE.md`。
## 安装
```sh
pi install git:gitea.vhkd.top/geekinney/skill-interface.git@v0.1.0
```
装完 `pi-skill list` 能看到 `skill-interface`,`pi-skill skill-interface check` 会告诉还缺什么。

5
package.json Normal file
View File

@ -0,0 +1,5 @@
{
"name": "skill-interface",
"version": "0.1.0",
"description": "所有技能的统一切入点:技能目录、用法转发、PATH 入口注册与契约检查"
}

View File

@ -0,0 +1,515 @@
# 技能契约(标准细则)
> 本文件是标准本身。SKILL.md 是给模型看的**选路表**,本文件是给技能作者与维护者的**检查表**。
> 机器判定入口:`skillcheck`(只报告,不改动)。
> 面向使用者的操作手册(命令/参数/场景步骤):[`references/user-guide.md`](references/user-guide.md)。
## 0. 第一性原理
**技能不是给人看的说明书,是给模型用的接口。** 接口的唯一目标:
> **最弱的模型也能一次调对、一眼判对、一步恢复。**
会出错的原因只有 7 种,标准就是逐条消灭它们:
| # | 失败形态 | 消灭手段 |
|---|---|---|
| 1 | 不知道有此命令 | `pi-skill list` 可发现;技能自描述 |
| 2 | 记错语法/路径/解释器 | 入口唯一,路径与 runner 由接口解析 |
| 3 | 不会拼多步 | 组合固化为命令;模型只做单次选择 |
| 4 | 看不懂结果 | 稳定字段 + 结论行;不要求解析散文 |
| 5 | 判不出成败 | 退出码语义 + `error=<code>` |
| 6 | 拿到陈旧/错误数据 | `source=`/`as_of=`;缓存永不单独作为答案 |
| 7 | 失败后不知道下一步 | `hint=` 给出可直接执行的修复命令 |
| 8 | 改一个技能,不知道会影响谁 | `dependsOn` 声明 + `pi-skill impact` 影响面(声明必须可被静态反查,见 §9) |
**验收判据**:1 次调用、0 次路径拼写、0 次输出解析、失败自带下一步。
## 1. 四契约
### ① 调用契约
- 模型可见入口唯一:`pi-skill <技能> <命令> [参数...]`(`interface.json` 的 `entry` + `runner` 决定实际 argv)。
- 参数类型与默认值由该技能 `help` 给出;未知参数/缺必填 → 退出码 2 + usage。
- 全局统一旗标(各技能同名同义):`--json --dry-run --yes --timeout <秒> --quiet`。
- 非幂等/破坏性命令**默认预览**,必须显式确认(`--yes` / `--apply`)才执行。
- 入口必须**与 cwd 无关**(用自身路径定位资源;`skillcheck` 会在无关 cwd 下复测 `help`)。
### ② 结果契约
- stdout 首行是结论(人读);随后是 `key=value` 块(机读)。字段名 ASCII、跨版本稳定;人读文案可变。
- 或直接输出**稳定 JSON**(字段同名),例如 `{"ok":true,"status":"created",...}`。
- `--json` 必须给出等价 JSON。大输出写文件并给 `path=`,不塞 stdout。
### ③ 失败契约(错误码封闭)
`usage` `deps` `platform` `auth` `config` `notfound` `conflict` `blocked` `timeout` `external` `internal`
- `config` 专用于**缺随人/随机器变化的值**(与 `deps`=缺软件、`auth`=缺登录态、`blocked`=流程中途要人 区分开);输出带 `missing=<键>` 与可直接执行的 `hint=pi-skill X setup --<键> <值>`,让人与模型都知道下一步。
- 输出固定形如:`error=auth hint=pi-skill dmp-submit login`。
- 退出码只区分三类:**0 成功**;**2 调用错误**(读 help);**其它非零 = 执行失败**(以 `error=` 为准)。
- `blocked` 专用于需要人介入:给 `need=human` 与恢复命令,且恢复必须幂等。
### ④ 数据契约
- 权威优先级:**目标程序的公开查询接口 > 官方发布产物 > 内部缓存**;缓存永不单独作为答案。
- 读取类输出带 `source=` 与 `as_of=`;超龄要么自动刷新,要么 `error=stale`。
- 读别的程序的内部文件 = 无保障契约:标 `confidence=`,关键判断必须与它的公开接口对账。
- **修改类命令:validate → write → 回读并输出生效值**,禁止只报「已写入」。
## 2. 分级(T0–T3)
| 级 | 形态 | 必需条款 | skillcheck 强制项 |
|---|---|---|---|
| **T0** | 只读查询 / 文档型 | 入口 + `help` + 字段 + 错误码 | `help-works`、`help-cwd-independent` |
| **T1** | 写入/修改 | T0 + 破坏性命令的干跑与显式确认 + 回读 | `contract-dryrun` |
| **T2** | 常驻/有生命周期 | T1 + `status/start/stop/logs` 四件套、start/stop 幂等、状态文件、崩溃后 `status` 能自愈判断、**liveness 证据**(`status` 答得出「上次真的动过是什么时候」) | `contract-lifecycle`、`contract-liveness`(warn) |
| **T3** | 长时/远端/批处理 | 续跑/断点续传 + 单写入者锁 + 超时或重试(+ 状态落盘、远端原文回传) | `contract-tier3` |
分级写在 `interface.json` 的 `tier`;**skillcheck 只强制该级条款**——不为过标准把小工具做成三层架构。
T2 与 T3 是两种正交的形态:批处理工具(如 `modelscope-download`)不需要守护进程四件套;
常驻进程(如 `syswatch`/`sysflood`)也不需要断点续传。只有同时具备两种形态时才同时声明
(此时写 `tier: 3` 并显式列出 `lifecycle`,两者都会被检查)。
**liveness 证据的主语是「持续承诺」,不是接口形状**:这条要求对三类主体是同一句话——
`status` 必须答得出「上次真的动过是什么时候」。守护(自持循环)报心跳(`last_sample`/`last_check`),
管理外部对象的报上次探测/对象状态时间(`as_of`),管任务的报任务最近动作(`last_write`)。
反例是 sysflood 旧版:四件套齐全、`status` 只说 `running pid=…`,于是悄声烧了 17 天 CPU。
不要用「声明我不是守护」来免除它——要求的是**证据**,拿不出证据就说明这个技能还没有能力
回答「你还活着吗」(历史上就先加过一个 `resident: false` 开关,被用户点破后删掉了)。
## 3. 场景覆盖矩阵
| # | 场景 | 必需条款 |
|---|---|---|
| 1 | 只读查询 | T0 + `source/as_of` |
| 2 | 写入/修改 | T1 + 回读生效值 |
| 3 | 破坏性/不可逆 | 默认预览 + 精确白名单 + 显式确认(外部工具无法干跑时声明 `dryRun: "external"` 并在文档写清风险) |
| 4 | 需登录态 | `auth` 错误码 + 自带 `check`/`login` 命令 |
| 5 | 需人工介入 | `blocked` + `need=human` + 可恢复(resume 幂等) |
| 6 | 长任务/常驻 | T2:四件套 + pid/state 文件 + 日志 `path=` + **liveness 证据**(`liveness` 字段:`status` 能答「上次真的动过是什么时候」);幂等 start/stop(当前实例:`syswatch`、`sysflood`) |
| 7 | 跨平台 | frontmatter `platforms` + `deps` 检查给可执行提示 |
| 8 | 远端(ssh/跳板) | 远端 stderr 原文回传 + 超时 + 断线语义 |
| 9 | GUI/浏览器/桌面自动化 | 前置 `check`(可达性/登录态)+ `notfound` + 现场证据路径(截图/HTML) |
| 10 | 批量/循环 | T3:`--limit/--resume` + 断点续传 + 单写入者锁(当前实例:`modelscope-download`) |
| 11 | 多技能编排 | 组合只在命令内部;输出 `step=` 序列,失败给 `failed_step=` |
| 12 | 外部命令入口(无法改源码) | 在文档里写清续跑/并发/破坏性行为;声明 `dryRun: "external"`;把危险且非必需的子命令从 `commands` 里去掉 |
## 4. 命令面设计规则
1. **接口 = 模型该用的命令,不是工具的全部能力**:危险且非必需的子命令(例如会删数据的 `clean`)不写进 `commands`。
2. 组合(配方)一旦高频或易错,**固化为一条命令**(薄封装,内部仍调原语)。文档只留选路与失败表。
3. 命令名用动词或稳定名词(`nav`/`click`/`verify`),不要 `do`/`run`/`handle` 这类空词。
4. 一个技能**一个模型可见入口**;运维脚本登记在 `interface.json` 的 `admin[]`(不给模型用)。
5. `SKILL.md` 目标 ≤60 行、硬上限 150 行(skillcheck 超限报 warn);且**前 60 行必须自成选路表**
(含 `pi-skill` 入口与失败→动作),长表格与实测细节移 REFERENCE.md。硬压到 120 行以下会把
高频需要的语义表挤出文档,反而逼出额外往返(实测运维型技能需要 100–150 行)。
## 5. interface.json 字段
| 字段 | 必填 | 说明 |
|---|---|---|
| `summary` | ✔ | 1 句话(≤140 字):这技能做什么 |
| `useWhen` | ✔ | 何时用它(模型选路用) |
| `tier` | ✔ | 0..3 |
| `entry` | 视情况 | `{ "path": "scripts/x.mjs" }` 或 `{ "command": "外部命令" }`;文档型技能省略 |
| `runner` | 否 | `node`/`python3`/`bash`/`zsh`,省略则按扩展名推断 |
| `aliases` | 否 | PATH 短命令名(高频技能才给) |
| `commands` | 否 | `[{ name, summary, destructive? }]`;旗标型工具可为空数组 |
| `admin` | 否 | `[{ path, summary, alias?, runner? }]` 运维入口 |
| `publish` | 否 | **对外发布**:`{ repo, mirrors?, package_repo? }`。`repo` 是 CLI 仓(命名统一 `<工具>-cli`),`package_repo` 是 Pi 包仓(默认由 CLI 仓名去掉 `-cli`);发布用 `skill-release cli|package`,载荷与操作见 §12 |
| `lifecycle` | T2+ | 必须含 `status/start/stop/logs` |
| `liveness` | T2+ | `status` 输出里承载「上次真的动过」这一证据的字段名:守护用心跳(`last_sample`/`last_check`,宜配 `health=ok/stale`),管理外部对象用 `as_of`,管任务用 `last_write`。语义:只声明不打印,或 status 报不出时间,都会被 skillcheck 抓出来 |
| `errors` | 否 | 该技能可能返回的错误码子集 |
| `dryRun` | 破坏性命令时 | `true`(实现 `--dry-run`)或 `"external"`(外部工具无法干跑) |
| `helpListsCommands` | 否 | 默认 `true`:`help` 必须列出声明的命令(skillcheck 交叉校验) |
| `probeUnknownCommand` | 否 | `true` 时 skillcheck 会验证「未知命令 → 非 0 退出码」,只对无副作用工具开启 |
| `status` | 否 | `stable`(默认)/ `experimental` / `deprecated`;退役流程见 ROADMAP P6.2 |
| `dependsOn` | 否 | **硬依赖**(子集式):`[{ skill, commands?, tierAtLeast?, note? }]`。只写真正用到的命令;软引用(文档提及)不写这里 |
| `requires` | 否 | 外部环境:`[{ kind: binary\|runtime\|service\|account, name, min?, install?, check?, note? }]`;运行时体检归 `check` 命令;skillcheck 反向对账脚本里 spawn/which 的程序(`requires-undeclared`,无处不在的命令与其它技能名/别名不算) |
| `demo` | 否 | **这个技能该怎么被演示**(给 `demo-publish survey` 用):`{ title?, summary?, pause?, narrator?, setup?, steps[] }`;`narrator: "ai"` = 说明也让 Agent 原样打印(Agent 类技能用,旁白别用 echo),`setup: ["agent"]` = 开演前先在 tab 里跑掉、不进正片。写法与评审清单见 `pi-skill demo-publish help`(**`say` 写台词不写说明文**:破折号、翻译腔词、「人话」、40 字长句、一句两个冒号会被校验拦下,对照表见 demo-publish 的 REFERENCE「写台词」一节),步骤形状同 demo-publish 的 plan——每步只能有 `cmd`(打命令并回车)/ `type`(只打字不进车,须紧跟一步 `key`)/ `key`(按键,如 `enter` `ctrl-enter` `ctrl-u`)/ `say`(打一行说明)/ `pause` 之一,可带 `expect`/`wait`/`hold`/`title`。**优先演用户真实路径**(在终端里怎么用),别拿 `pi-skill <技能> xxx` 的子命令输出来凑;每步 ≤ ~14 行输出、每步前面有 `say` 说明、`type` 之后必须紧跟 `key`。没声明不罚,但 `survey` 只能退回只读命令清单(等于演不出典型功能)。形状错误由 `skillcheck` 的 `contract-demo` 拦下 |
| `config` | 否 | 随人/随机器变化的值:`[{ key, desc, type?, values?, required?, requiredFor?, discover?, ask?, secret?, default?, note?, ttl?, when?, setupFlag? }]`;`secret` 不得带字面量默认值;`setupFlag` 只在技能的 setup 用了别的旗标时写(如 `--url`),`pi-skill doctor` 的 `修:` 行据此拼接;语义见 §11 |
## 6. skillcheck 检查项
| check | 级别 | 含义 |
|---|---|---|
| `interface-json` / `interface-*` | error | 声明缺失或字段非法 |
| `entry-exists` / `entry-runnable` | error | 入口文件缺失 / 无法执行 |
| `platforms` | error | frontmatter 缺合法 platforms(Pi 不会加载) |
| `doc-entry` | error/warn | 文档未指向 `pi-skill`(warn:未给出 `pi-skill <技能>` 形式) |
| `doc-relative-path` | error | 文档用裸相对路径调用脚本(模型 cwd 不是技能目录) |
| `doc-length` | warn | SKILL.md 超 150 行 |
| `doc-scannable-top` | warn | 前 60 行缺入口或失败→动作 |
| `secret-literal` | error | 文档/接口出现疑似密钥字面量 |
| `entry-main-guard` | error | 用字符串比较判断主入口(符号链接路径会让入口静默不执行、退出码 0)→ 两侧 `fs.realpathSync` 再比 |
| `contract-dryrun` | error/warn | 破坏性命令缺干跑能力;`external` 时 warn |
| `contract-lifecycle` | error | T2(或声明了 lifecycle 的 T3)缺四件套 |
| `contract-liveness` | warn | T2(或声明了 lifecycle 的 T3)没声明 `liveness`,或声明的字段在入口源码里找不到 ——「进程在跑」不等于「还在干活」(`status` 必须答得出上次真的动过是什么时候) |
| `contract-tier3` | error/warn | T3 缺续跑 / 单写入者锁 / 超时重试(外部命令入口只能 warn) |
| `contract-errors` | error | 用了封闭集合之外的错误码 |
| `contract-demo` | error | `demo` 声明形状不对(步骤类型混淆、`key` 不认识、`expect` 为空等) |
| `contract-missing` | warn | 还没有 `contract.lock.json` 契约基线 → `skill-contract update <技能> --apply` |
| `contract-drift` | error | 接口面出现破坏性变化(删命令/降级/移除平台或别名)→ 先留 shim 或改依赖方,再更新基线 |
| `contract-additive` | warn | 接口面只有向后兼容的新增 → 确认后更新基线 |
| `help-works` | error | `help` 非 0 退出或未识别 |
| `help-cwd-independent` | error | 在无关 cwd 下 `help` 失败 |
| `help-commands` | error | 声明的命令在 `help` 里找不到 |
| `help-latency` | warn | `help` 冷启动 > 100ms |
| `usage-exit-code` | error | 未知命令返回 0(仅 `probeUnknownCommand` 时跑) |
| `alias-missing` / `alias-drift` / `alias-permission` | error | PATH 入口缺失/漂移/无执行位 → `skill-entry-install run` |
| `alias-orphan` | warn | `~/.local/bin` 里指向本仓库但未声明的入口 |
| `admin-undeclared` | warn | `scripts/` 下未登记的脚本 |
| `publish-shape` | error | `publish` 声明形状不对(缺 `repo` / `mirrors` 不是字符串数组) |
| `publish-naming` | warn | 镜像仓名不以 `-cli` 结尾(公开 CLI 工具仓统一 `<工具>-cli`) |
| `publish-version` | error | 声明了 `publish` 但没有 `VERSION`(发布要靠它打 tag) |
| `deps-resolve` | error | `dependsOn` 引用了不存在的技能 |
| `deps-commands` | error | `dependsOn` 里的命令名在被依赖技能中不存在 |
| `deps-tier` | error | 被依赖技能的分级低于 `tierAtLeast` |
| `deps-cycle` | error | 依赖环(hard ∪ declared 边,全库检测) |
| `deps-platform` | warn | 依赖的技能不在本技能支持的平台上(确认有降级路径后可忽略) |
| `deps-undeclared` | error | 脚本里调用了某技能,但 `dependsOn` 未声明(D18 起为 error:调用事实与声明必须一致) |
| `deps-unused` | warn | 声明了依赖但脚本里找不到实际调用 |
| `config-declared-unused` | warn | config 声明了某键,但 scripts/ 里从没出现这个名字(声明烂掉/键名写错) |
| `config-env-undeclared` | warn | 读了 `PI_SKILL_<技能>_<键>` 环境变量,但 config 没声明该键 |
| `doc-first-use` | warn | 有必需配置/外部依赖,但 SKILL.md 前 60 行没写首次使用怎么检查(别人拿到包会卡在缺配置上) |
| `setup-command` | warn | 声明了 config 但没有 `check`/`status` 命令:缺配置只能等主命令失败才知道(`status` 也算,Windows 侧习惯用它) |
| `requires-undeclared` | warn | 脚本里 spawn/which 了外部程序但 `requires[]` 未声明 → 新机器上装什么靠猜 |
| `deprecated-consumers` | error | 标了 `status: deprecated`,但还有技能在脚本里硬依赖它 → 先改调用方,再退役 |
| `deprecated-note` | warn | 退役技能的 SKILL.md 前 40 行没写「已退役/替代技能」,模型还会照老文档调用 |
| `pack-leak` | error | (`pi-skill pack`)包内出现密钥/真实家目录/真实工单号——只扫白名单内的文件 |
## 7. 新技能准入(提交前必须全绿)
1. `interface.json` 齐全,`tier` 与命令面自洽;
2. `SKILL.md` 指向 `pi-skill <技能>`,无裸相对路径,无密钥;
3. `skillcheck <新技能>` 无 error;
4. `skill-entry-install run` 后 `alias` 可用(若声明了别名);
5. 只读命令真跑一次(`--dry-run` 或直接调用),确认字段与错误码符合契约。
## 8. 调用留痕与 stats(接口演进的数据来源)
`pi-skill` 在每次调用退出前追加一行 JSON 到 `<agentDir>/local/skills/skill-interface/invocations.jsonl`(超 20MB 轮转一份到 `invocations.1.jsonl`)。
只记**形状**,不记内容:参数值、输出正文、cwd 都不写。留痕失败静默,永不影响转发。
| 字段 | 含义 |
|---|---|
| `ts` | ISO 时间 |
| `skill` / `command` | 技能名(按调用方所写,不存在时 `resolved=false`)/ 第一个位置参数,无则 `help`;`pi-skill` 自身的 `list/help/stats` 记为 `skill-interface` |
| `argc` / `flags` | 位置参数个数 / `--旗标` 名(去值、去重、最多 12 个) |
| `exit` / `error` | 退出码 / 从输出尾部扫到的 `error=<code>`(仅非 0 退出时;退出码 2 而无码时补 `usage`) |
| `ms` | 耗时 |
| `session` / `model` | `PI_SESSION_ID` / `PI_MODEL`,没有则 null |
环境变量:`PI_SKILL_LOG=0` 关闭;`PI_SKILL_LOG_DIR` 覆盖目录。
`pi-skill stats [技能] [--since 7d|24h|2w|all] [--json]`(默认 30d)回答四问:哪些技能/命令从未被调用;哪些命令失败率最高(≥ 3 次)及常见错误码;模型叫了哪些不存在的技能名(路由信号);每个技能最近一次被用是什么时候。
`--json` 顶层键稳定:`ok source as_of since total failures usage_errors errors skills[] never_called_skills unknown_skills top_failing_commands`;`skills[]` 每项 `name calls failures usage_errors errors last avg_ms commands[] never_called_commands`。
覆盖边界:只有经 `pi-skill` 的调用被记录;直接跑技能别名(`cdpctl …`)不进统计。
## 9. 依赖与影响面(技能库是滚动发行版)
技能库不是 npm:运行时只有**一个全局命名空间**(`pi-skill X` 永远解析到磁盘上唯一一份),没有 linker,装不了两版;消费者是模型,接口变了不会编译报错,只会静默用错。所以依赖管理的目标是:**让破坏性变更不可能悄悄发生**。手段是四件:契约指纹/基线(Phase 2)、依赖声明、兼容窗口(shim)、影响面查询。本条只约束前两件的声明与反查。
1. **硬依赖必须声明**(`dependsOn`),且按「用到的面」声明子集:命令名、最低分级。声明必须与事实对账:
`skillcheck` 会静态反查脚本里出现的 `pi-skill <目标>`(含别名归一)与跨技能相对 import;只声明不调用报 `deps-unused`,调用不声明报 `deps-undeclared`。
脚本里的 hint 文案注意区分:**真会执行**的恢复路径(如 `hint=pi-skill X nav …`)算硬依赖(目标改名一样会让它失效);纯给人看的跨平台提示(同行为中文文案)算软引用,不强迫声明(否则满仓假依赖与假环,量尺就废了)。
2. **软引用不进声明**:只在 `SKILL.md`/`REFERENCE.md` 提到某技能,属于选路提示,不是依赖;要当依赖用就写进脚本并声明。
3. **改接口前先看影响面**:`pi-skill impact <技能> [命令] [--json]` 给出谁在脚本里调用(hard)/谁只文档提及(soft)+ 近 30d 调用量;删除/改名命令的前置条件是「近 N 天 0 调用 + 0 硬依赖 + 已留 shim」。
4. **不做的**:多版本共存、手写 semver 范围约束、锁 commit、registry 服务器——它们要么没有执行点,要么制造虚假保证。演进规则与迁移批次见 `ROADMAP.md`(本目录)。
## 10. 契约基线与兼容窗口
只有一个全局命名空间,就没有“装旧版”这条退路;保护依赖方的唯一手段是**变更可见 + 兼容窗口**。
**基线是什么**:每个技能目录里的 `contract.lock.json`(生成物,随 Git 提交),记录接口面——`commands`(含 `destructive` 标记)、`errors`、`tier`、`platforms`、`aliases`、admin 别名。**不含实现**:入口内容、help 文案、summary 文本变化不算接口变化;只有接口面变化才报漂移。
```bash
skill-contract # 谁缺基线 / 谁漂移(0 无漂移,3 有待办)
skill-contract diff --all # 逐条列出 breaking / additive
skill-contract update --all --apply # 显式接受当前接口面,写入基线
```
分类与动作:
| 变化 | 级别 | 动作 |
|---|---|---|
| 删命令 / 命令 `destructive` 标记变化 / 降 tier / 移平台 / 移别名 / 不再声明某错误码 | breaking | 先做兼容:留旧名 shim(旧名仍可调,返回 `error=usage hint=pi-skill X <新名>`),或改完所有依赖方;确认后才能更新基线 |
| 新增命令/错误码/别名/平台、升 tier | additive | 兼容变化,不挡提交;确认后更新基线 |
**兼容窗口约定**(针对改名/删除):
1. 先加新名、保留旧名 shim,两个名字至少并存一个发布周期;
2. shim 也要能被 `impact` 看见(它调用新名,旧名声明为别名则自动可查);
3. 删除前置条件:`pi-skill impact <技能> <命令>` 或 `skill-contract diff` 确认 0 硬依赖 + 近 30d 0 调用,且旧名错误信息已指向新名一个周期;
4. 漂移检查在 `skillcheck` 里是 error,所以“忘了同步基线”不会静默通过——它会在提交时强制你面对一次“这是不是破坏性变更”。
**改名检查单**(照单跑;`telegram-publish → telegram` 就是这么走的,八步一步没省):
| # | 动作 | 不做会怎样 |
|---|---|---|
| 1 | `git mv skills/<旧名> skills/<新名>`(入口脚本一起 mv) | 历史断成「删一个 + 加一个」,`blame`/`log` 找不到来路 |
| 2 | `interface.json`:`entry.path` 指新入口;`aliases` 收旧名(长期并存,不是一次性 shim) | 旧名当场消失,依赖方炸 |
| 3 | `bash skills/skill-interface/scripts/install.sh run` | `skillcheck` 报 `alias-missing`(error):PATH 上还缺旧名入口 |
| 4 | 本机数据目录跟着搬:`~/.pi/agent/local/skills/<旧名>` → `<新名>` | config/session/venv 留在旧名下,表现是「莫名其妙掉登录态」;venv 换路径仍然能用,不必重建 |
| 5 | 环境变量前缀换 `PI_SKILL_<新名>_*`(文档与测试里的字面量一起改) | 用环境变量喂凭据的机器会读不到(本机走 config.json 看不出来) |
| 6 | `pi-skill impact <旧名>` 找硬依赖,再加全仓 grep 旧名(文档 / 测试替身 / `dependsOn`) | 依赖方还调旧名:现在能跑,等旧名真退役就断 |
| 7 | `skill-contract update <新名> --apply` | 新目录 `contract-missing`,旧基线随旧目录一起没了 |
| 8 | 验收:`skillcheck <新名> <依赖方…>` 0 error;旧名/新名各实跑一次(`pi-skill` 与 PATH 入口两条路);硬依赖方跑一次它自己的 `check`/`status` | 改名「看起来成功」,但真正被调用的那条路没人试过 |
改名不是审美问题:**名字 = 职责**,功能边界变了(变大或变小)就得改。退役旧别名按上面的兼容窗口走(0 硬依赖 + 近 30d 0 调用),删时再留一个周期的 `error=usage hint=<新名>` shim;只是改名、不退役的话,别名一直留着就行。
## 11. 配置契约(随人/随机器变化的值)
技能本体(共享仓库、可分发)只放确定性逻辑。主机名、代码根目录、账号、路径这类**每个人/每台机器不一样**的值,一律:
1. 在 `interface.json` 的 `config[]` **声明**(这是 doctor / setup 引导 / skillcheck 的真源);
2. 值写 `local/skills/<技能>/config.json`(不进 Git)或环境变量,**不写进技能代码**。
```json
"config": [
{ "key": "remoteHost", "desc": "非 macOS 上转发目标的 Mac 地址", "type": "host",
"required": true, "when": { "platform": ["win32", "linux"] },
"discover": "~/.ssh/config 里的 Host", "ask": "要转发到哪台 Mac?" },
{ "key": "token", "desc": "API 令牌", "type": "string", "secret": true }
]
```
字段:`key`(脚本里用的名字)/ `desc`(必填)/ `type`(`string int bool path host url enum list`)/ `values`(枚举)/ `required` / `requiredFor`(只某些命令必需)/ `secret`(默认打码,禁字面量默认值)/ `discover`(自动发现方式描述)/ `ask`(缺值时问人的一句话)/ `default` / `ttl`(秒,超过就进 stale)/ `when`(如只在某些 platform 上要求)。
**优先级**:CLI 参数 > 环境变量 `PI_SKILL_<技能>_<键>` > `local/skills/<技能>/config.json` > 自动发现 > `default`。有自动发现就先发现,再问人;能不问就不问。
实现跟着 `scripts/skill-config.mjs`(reference implementation):`resolveConfig` / `configErrorLines` / `writeConfig` / `maskValue`。缺必需值时输出:
```
error=config missing=remoteHost need=human
ask="要转发到哪台 Mac?"
hint=pi-skill reminder setup --host user@mac
```
配套命令约定:
- `pi-skill doctor [--json]`:一次列出全库缺配置/缺依赖的技能与一条修法(新机器上手第一条命令);
- 有 config 的技能应提供 `check`(只读自检,任何时候可重复跑)与 `setup`(写入单个键);
- `skillcheck` 对声明烂掉(`config-declared-unused`)与未声明就用的环境变量(`config-env-undeclared`)报 warn。反向检查(声明→代码)比正向扫描 `config.key` 代码模式可靠,见 ROADMAP D14。
边界:**配置检查不进 `pi-skill` 转发热路径**(性能)——只在 doctor / 显式 check / 命令自身失败兜底时发生。
## 12. 可分发契约(分享给别人也「无脑可用」)
技能是可分发的(拷目录 / `pi-skill pack` / 随仓库同步),但**包里不该有本机事实**。三件事各自成检查:
**① 环境前置**:`requires[]` 声明外部依赖(`binary runtime service account`),写清 `install` 与 `check`。体检交给本技能的 `check` 命令与 `pi-skill doctor`(doctor 对 binary/runtime 查 PATH,对 service/account 标 `needs_manual`)。缺依赖时报 `error=deps` + 一条修法,不猜、不自装。
**② 首次使用引导**:有必需配置或外部依赖的技能,SKILL.md 前 60 行必须出现 `check`/「首次使用」类指引(skillcheck `doc-first-use`),并(有 config 时)提供 `check` 或 `status` 命令(`setup-command`)。别让新用户靠主命令报错来发现要配什么。
**config 声明的边界**(D20):只声明「首次使用前必须由人给、且缺了会卡住」的值。两种情况不声明——① 由技能自己的 add/login/setup 之类命令写入的集合型配置(观测项、站点清单、项目表),它们的第一用法就是那条命令;② 属于别的程序的配置(如 sing-box 的 config.json)。声明了就要保证 `pi-skill doctor` 里这一行是真信号,不能变成噪声。
**③ 打包白名单**:`pi-skill pack <技能> [--out <目录>] [--dry-run] [--json]`
- 只装:`SKILL.md`/`REFERENCE.md`/`interface.json`/`contract.lock.json`/`CHANGELOG.md`/`LICENSE`/`VERSION` + `scripts/ assets/ templates/ references/`;
- 绝不装:点开头、`node_modules`、`__pycache__`、`*.pyc`、`*.log`、`*.jsonl`、`config.json`、`state.json`、`.env*`、`*.bak/.tmp/.sqlite`;
- 泄漏扫描(只扫包内文件,绝不因扫描读写包外数据):`secret`(`sk-`/`ghp_`/`github_pat_`/`AKIA`)、私钥块、`password|token|api_key = "…"` 字面值、真实家目录、真实工单号 → error(阻断);邮箱、内网域名 → warn(自己判断)。占位符(`/Users/<你>/`、`C:\\Users\\<你的用户名>\\`、`DEMO_FUNC_20260101_0001`)与键名表里的 `home/end/space` 不算泄漏;
- MANIFEST.json:`manifest_version`、版本(`git describe`;无 tag 时用短 commit)、`files[]`(path/bytes/sha256)、`commands/errors/aliases`、`depends_on/requires/config`、`external_imports`(跨技能相对 import,包外依赖要一起给)、`contract` 快照、`leak_scan` 结论;
- 退出码:0 已打包 / 3 被拦或基线漂移 / 2 调用错误;`--force` 只越过泄漏门禁(契约与结构错误仍拦)。
**④ 发布(两个出口)**:`skill-release` 把声明了 `publish` 的技能发布成公开仓——`cli`(`<工具>-cli`,给所有人)或 `package`(`<工具>` Pi 包仓,`pi install` 一条命令装技能)。
- **命名**:仓名 `<工具>-cli`(交付物类型一眼可辨,不与官方客户端/Emacs 插件混淆;Emacs 插件是 `etaf-*` 一族),装出来的**命令**仍是 `<工具>`(不带后缀),技能名也是 `<工具>`——三者对齐、各管一段。
- **声明**:`interface.json` 的 `"publish": { "repo": "<可推送的 URL>", "mirrors"?: ["…"] }`;`VERSION`(语义化版本)是 tag 的来源。
- **一个源、两个产物**:①技能包 = `skill-pack`(给 Pi 圈:带 `SKILL.md`/`interface.json`/`contract.lock.json`,不带 `tests/`);②CLI 仓 = `skill-publish`(给所有人:`scripts/` + `tests/` + `VERSION` + `REFERENCE.md` + 生成的 `README.md` + `MANIFEST.json` 溯源,**不带** Pi 元数据)。门禁只有一套(skill-pack 的白名单/泄漏/skillcheck+契约不漂移)。
- **发布**:`skill-release cli <技能> [--dry-run]` / `skill-release package <技能>` —— 先过 `skill-pack` 门禁 + 技能目录必须已提交(记账的提交要对得上发布内容);载荷按上一条组装;整体覆盖目标仓 → 提交 `release v<版本>` → push `main` + tag `v<版本>`;`MANIFEST.json` 的打包时刻算易变元数据(同一份内容重复发布 = 无变更);同一版本号不重发不同内容。
- **包仓载荷**:`skills/<技能>/`(技能本体)+ 生成的 `package.json` + `README.md`(含 `pi install git:…@v<版本>`)+ `MANIFEST.json`;对方 `pi install` 后 `pi-skill list` 可见。
- **清点**:`skill-release list [--json]` 一条命令回答「两个出口各自发到哪、版本、发布后又改过没有」(每个出口各记自己的发布提交,记账在 `local/skills/skill-interface/release.json`)。
- **不可变**:同一个版本号不再发不同内容;改了就抬 `VERSION`。镜像仓是生成物——**改代码只改技能目录,改完重新发布**,不要在镜像仓手改。
- **安装**(对方侧):clone 后跑技能里的 `scripts/install-cli.sh`(链成 `~/.local/bin/<工具>` 并检查依赖),或把仓目录放到任意位置、在消费方(如 Emacs 包)里配置命令路径。
**分发出去的定义**:对方把目录放进 `<shared>/skills/` 后,`pi-skill list` 能看到、`pi-skill <技能> check` 能告诉他还缺什么、`skillcheck <技能>` 无 error(tests/ 不随包,clean-room 用包内入口自检;见 `tests/pack.test.mjs` 的 clean-room 用例)。
**两条出口怎么选**(同一个源;按接收方选一条或都要):
| 想让谁用 | 出口 | 操作 | 接收方拿到后 |
|---|---|---|---|
| Pi 用户(装进 Pi 用) | `skill-pack` | `skill-pack <技能>` → `<out>/<技能>-<版本>/` + `.tar.gz` | 解压放进 `<agent>/shared/skills/`,`pi-skill list` 看得到 |
| 任何人(命令行,不依赖 Pi) | `skill-release cli` | interface.json 声明 `publish.repo` → `skill-release cli <技能>` | `git clone` + `scripts/install-cli.sh`,命令 `<工具>` 进 PATH |
| Pi 一条命令装(npm/git 包) | `skill-release package` | 建一个仓(名用 `<工具>`)→ `skill-release package <技能>`(自动生成 `package.json` 与 `skills/<技能>/`,不手工复制) | `pi install git:<host>/<owner>/<工具>@v<版本>`(或 npm 发布后 `pi install npm:<包>`) |
- Pi 没有自己的私有 registry:官方安装走 **npm 或 git**;`badlogic/pi-skills` 那类官方 collection 是一份目录索引,进它要提 PR。
- 三条出口共用同一道门禁(skill-pack),差别只在「装什么、给谁」;不发布就不用声明、不用建仓。镜像仓/包仓都是生成物——**改代码只改技能目录**。
---
## 13. 增量验证、巡检与退役(Phase 6)
### 13.1 提交前只跑该跑的(`skillcheck --changed [<ref>]`)
```
skillcheck --changed # 对比工作区(含未跟踪文件)
skillcheck --changed origin/main # 对比已提交区间 <ref>...HEAD
```
输出 `待查:`(改动的技能 + **硬依赖**它的技能)与 `待跑:`(可直接粘的命令:涉及技能自己的 tests + 契约全套);`--json` 时整段放在 `changed_scope` 里(`changed` / `affected` / `mentioned` / `tests`)。
只在文档里提及的(软引用)列进 `mentioned`,不进待跑清单——它们不会因为我改接口而坏,但改接口时值得看一眼。
### 13.2 定期巡检(`pi-skill audit`)
一条命令给出五块信号,只报告不改动;`--quiet` 干净时完全静默(适合 `job-schedule` 每天跑):
| 块 | 信号 | 看到之后做什么 |
|---|---|---|
| 路由 | 调用日志里「不存在的技能」、高频失败命令 | 补别名 / 改提示文案 / 让错误提示自解释(排除 PATH 上的外部命令) |
| 重叠 | `useWhen`+`summary` 词集重叠 ≥ 0.42 的技能对 | 复核是不是两份技能抢一个触发场景 → 合并或改 useWhen |
| 膨胀 | 命令数 > 12、SKILL.md > 120 行、文档型却声明命令 | 拆分或把细节移进 REFERENCE.md(硬门禁仍是 150 行) |
| 重复实现 | 命令名集合重合 ≥ 0.6 的两两技能 | D10:第三次出现才提取共享原语,前两次先记录 |
| 生命周期 | `deprecated` 仍有硬依赖;近 N 天 0 调用、0 依赖 | 走退役流程,或确认保留理由 |
`audit` 的判定是**启发式**(文本相似、阈值都可调),结论必须人来复核;它不给「必须改」的判决,只把可疑处排到眼前。
### 13.3 退役流程(`skill-retire`)
```bash
skill-retire <旧技能> --to <新技能> # 预览(默认,不落盘)
skill-retire <旧技能> --to <新技能> --apply # 落盘
```
门禁(先查再说):① **没有硬依赖**(有人脚本里真调用它 → 先改调用方;这条不可越过);② 近 30 天 0 调用(本人刚用过可 `--force`,但要有理由)。
落盘动作:把该技能**自己的入口文件**换成指路牌(声明了 `entry.path` 就用它的路径;缺省时生成 `scripts/retired.mjs`;老名字仍可调用,输出 `error=notfound` + `hint=pi-skill <新技能> help`)、`commands` 清空、`status: deprecated`、别名保留、SKILL.md 顶部加退役说明、契约基线同步更新。
**不删目录**:老提示文案、老笔记、别人机器上的别名都还能落到指路牌上;真要物理删除是另一件事(`impact` + `stats` 证明 0 调用 0 引用后再做)。
## 14. 状态文件版本化(`local/skills/<技能>/state.json`)
技能自己的状态文件(历史值、上次检查结果、运行标记)会随技能演进换形状。约定:
- 任何状态文件都带 `_schemaVersion`(整数);当前版本由技能源码里的常量声明。
- 读写一律走原语 `skill-interface/scripts/skill-state.mjs`(`loadState` / `saveState` / `describeState`):
```js
import { loadState, saveState } from '../../skill-interface/scripts/skill-state.mjs';
export const STATE_VERSION = 2;
const state = loadState(file, {
version: STATE_VERSION,
migrate: (data, from) => ({ data: { ...data, hits: data.count ?? 0 }, notes: ['count → hits'] }),
});
if (state.error === 'newer') { /* 用户装过更新的技能又回退:不猜、不覆盖,提示人 */ }
saveState(file, { ...state.data, hits: 3 }, { version: STATE_VERSION });
```
- **低版本**:调 `migrate` 升上来,升级前自动备份 `<file>.v<旧版本>.bak.json`;`dryRun: true` 只给计划不动盘。
- **高版本**(本机技能被回退过):返回 `error: 'newer'`,**不迁移、不覆盖**——猜错就是静默丢数据。
- **没有版本号**:按 v0 处理(老文件平滑接入,正是这条约定能落地的前提)。
- 迁移是罕见事件,技能可以在 stderr 说一行 `describeState(result)`;高频命令里可加静音开关。
## 15. 问题清单 → 最佳实践对照(速查)
写新技能或改老技能时按这张表自查;每一行都对应一个可机器判定的检查或一条明确的约定(右列是守门人)。
| # | 常见问题 | 最佳实践 | 守门人 |
|---|---|---|---|
| 1 | 模型不知道有这技能 / 不知道什么时候用 | `summary` + `useWhen` 写成「何时用」句式;名字 = 职责 | `interface-*`(结构) |
| 2 | 技能文档写了命令,但入口不认识 | 命令面与 help 输出同源;改一边就同步另一边 | `help-commands` |
| 3 | 用户要拼脚本路径、记参数顺序 | 一个模型可见入口 `pi-skill <技能> <命令>`;运维脚本进 `admin[]` | `alias-missing` / `alias-stale` |
| 4 | 出错时给一堆栈,模型不知道下一步 | 封闭错误码 + `error=` + 可执行 `hint=`;退出码 0/2/其它 | `errors-*` / 文档失败表 |
| 5 | 输出字段名每版都变,脚本没法解析 | 结论行 + 稳定 `key=value`(或稳定 JSON);字段名跨版本不变 | 代码评审 + 本表 |
| 6 | 破坏性操作没有预览,手一抖就落地 | 破坏性命令必须 `--dry-run` 或默认预览 + `--apply`,写后回读 | `contract-dryrun` |
| 7 | 长时/远端任务断了重来,重跑两遍 | T3:续跑 + 单写入者锁 + 超时/重试 | `contract-tier3` |
| 8 | 常驻服务没有状态/启动/停止/日志入口 | T2:`status/start/stop/logs` 四件套,幂等 | `contract-tier2` |
| 9 | 技能 A 调技能 B,B 改名后 A 静默失效 | 声明 `dependsOn`(按真实用到的命令);改接口先看 `impact` | `deps-undeclared`(error) / `contract-drift` |
| 10 | 依赖只在某个平台上可用,别处直接崩 | 收窄 `platforms`,或在文档写明降级路径 | `deps-platform`(warn) |
| 11 | 换台机器少了外部程序,运行到一半才报错 | 声明 `requires[]` + 首次使用行 + `check` 自检 | `requires-undeclared` / `doc-first-use` |
| 12 | 配置项没声明,别人不知道该填什么 | 声明 `config[]`(`required`/`discover`/`ask`/`default`),缺值给 `error=config` + setup hint | `setup-command` / `config-*` |
| 13 | 配置键名 ≠ setup 旗标,提示照抄就报错 | 用 `config[].setupFlag` 对齐真实旗标 | `pi-skill doctor` 的 `修:` 行 |
| 14 | 密钥/真实家目录/真实工单号被分享出去 | 私有值放 `local/skills/<技能>/`;文档用占位符;分享前 `pack` 扫一遍 | `pack-leak` |
| 15 | 别人拿到包卡在第一步 | 包内带 `MANIFEST` + 首次使用行;clean-room 能跑 `help`/`check` | clean-room 测试(`tests/pack.test.mjs`) |
| 16 | 读了外部系统却没说是哪来的、什么时候的 | 读取类输出带 `source=` + `as_of=`;缓存不能单独当答案 | `external-source` |
| 17 | 掉登录才发现,主流程白跑一趟 | 有凭据的技能必须能报登录态(`check` 里覆盖) | `login-coverage` |
| 18 | 技能改坏了别人不知道 | 契约基线 `contract.lock.json`;破坏性变更报 `contract-drift`(error) | `skill-contract status` |
| 19 | 删技能/改名后老提示还指着旧名字 | 先 `skill-retire`(指路牌 + `deprecated`),不要直接删 | `deprecated-consumers`(error) |
| 20 | 改了技能不知道要跑哪些测试 | `skillcheck --changed` 给出「改动 + 硬消费者」与待跑清单 | `--changed` |
| 21 | 状态文件换了形状,历史值静默丢 | `_schemaVersion` + `skill-state.mjs` 迁移(先备份)、高版本拒写 | `tests/state.test.mjs` |
| 22 | 技能越写越大,最后没人敢动 | 单个技能只做一件事;细节进 `REFERENCE.md`;超预算就跑 `audit` | `doc-length` / `pi-skill audit` |
| 23 | 没人知道哪些技能其实没人用 | 留痕 `stats` + 定期 `audit`(0 调用 0 依赖 → 退役候选) | `pi-skill stats` / `audit` |
| 24 | 同一能力被实现三遍 | D10:第三次出现才提取共享原语;先记录 | `audit` 的「重复实现」块 |
| 25 | zsh 脚本里 `local status=$?` 直接报 `read-only variable`,且只在真实分支上炸(桩把前面直接 return 掉了) | 退出码/状态用非保留名(`rc`/`code`);`status`、`ARGC` 只读,`pipestatus`/`path`/`argv`/`functions`/`commands`/`options`/`signals` 不能当普通标量;单测要跑到真实分支 | 代码评审 + 本表 |
| 26 | 长驻子进程(录制器/临时代理/替身)没人收尾,残留数天占窗口与 CPU | 父退出时兜底收掉子进程 + 替身自限(§19);真身留个 `pgrep` 能查的名字 | 本技能用例(没有用例就当人判) |
> 表的用法:拿到一个新需求 → 先查本表有没有对应行;行里的「守门人」能自动判的,提交前必须全绿;只能人判的(第 5、15、23、25 行,以及没有用例的第 26 行)在评审时过一眼。
## 16. 共享小原语(`skill-interface/scripts/lib/`)
**同源复制出现第三次就提取到这里**(D10);技能侧留一个几行的薄包装,钉住自己的默认值,调用方写法不变。
| 原语 | 管什么 | 谁在用(薄包装位置) |
|---|---|---|
| `lib/args.mjs` | 旗标解析(全局旗标同名同义、未知旗标 usage、`--`、`-h`、int/float/list)、`requireFlag`、`timeoutMsOf`、`scanGlobals`、`positionalText` | telegram / demo-publish / x-social 的 `scripts/lib/args.mjs`(各自钉技能名,出现在报错 hint 里) |
| `lib/render.mjs` | 输出与错误契约:`emit`(结论 + k=v + items + source/as_of)、`fail`/`reportError`、`EXIT`、`ERROR_CODES`、`cell`、`nowIso` | telegram / demo-publish / x-social 的 `scripts/lib/render.mjs`(各自钉 `source=` 默认值) |
| `lib/dupguard.mjs` | 重复提交闸:同人 + 同目标 + 同内容在窗口内出现过就处理;台账 JSON 只留窗口内(默认 24h / 50 条);`mode: block`(默认,挡住)或 `warn`(照做但报出来) | x-social(`post/reply/quote/thread`,block + `--again`)、telegram(`send`,warn 到 stderr + `--again`) |
| `lib/notify.mjs` | 推手机:调共享通知扩展(`extensions/notifications/cli.mjs send`),返回 `{sent, reason}`,从不抛异常;**只在异常时推**,正常静默 | page-change-watch(`run --notify`)、x-social(`watch --notify`) |
约定:原语里**不许出现具体技能的业务逻辑**(路径、字段名、单条文案都由调用方传入);`apple-apps` 的错误模型不同(每个错误码一个退出码,见它的 `osascript.mjs`),不强行并进来——它是**事实差异**,不是例外开关。
## 17. 有凭据/账号的技能怎么写巡检
**什么时候需要**:技能手里握着会自己过期的东西(登录态、cookie、token、订阅额度)。用户不会每天去试,等真要用时才发现「掉登录了」——这类技能要能一句话自证还活着,坏掉的那一刻自己说话。
形状(照抄 `x-social watch`):
| 要求 | 为什么 |
|---|---|
| 一条只读命令查全部对象(`<技能> watch`),**逐个真验**一次,不看缓存时间戳 | 缓存新鲜 ≠ 还能用;每个对象单独取凭据真发一次请求 |
| 全好就安静退 0,不报「一切正常」 | 巡检是给定时任务用的,正常时刷屏等于逼用户把它关掉 |
| 坏掉的按封闭错误码报(`auth`/`external`)+ `need=human`,逐条列出「哪个对象、什么原因」 | 一眼看出是哪个号、要不要人动手 |
| hint 给一条**可直接执行**的修复命令,并把对象名填好 | 恢复成本压到一次粘贴(如 `login --profile "Profile 2"`) |
| `--notify` 只在异常时推(走 `lib/notify.mjs`,正常不响) | 守住「有事才响」,用户才不会把通知静音掉 |
| 阈值/档位型巡检:只在「比上次已提醒档位更差」时推一次,回到最好档时静默复位 | 每天推一次「依旧很低」等于逼用户静音;把已提醒档位记进状态文件,同一档不重复响,推送失败不写状态、下次重试 |
| `--json` 输出对象数组 | 上层能继续吃它(值变化型技能、报表都能接) |
挂定时(幂等、不在启动路径上):
```bash
pi-skill job-schedule add <技能>-<对象>-watch --cmd "pi-skill <技能> watch --notify" --at 09:20
launchctl kickstart -k gui/$(id -u)/com.pi.job-schedule.<技能>-<对象>-watch # 装完立刻实跑一次
pi-skill job-schedule status <名字> # last_exit=0 / runs=1 才算真通
```
**测试怎么测**(离线、不推真通知):把沙箱 agent 目录里的 `shared/extensions/notifications/cli.mjs` 换成把 stdin 写进文件的假实现,断言三件事——正常时**一次不推**、异常时**推一次**、正文里**只有坏掉的那个对象**。照 `x-social/tests/x-social.test.mjs` 的「巡检」用例写。
**别做**:不要为巡检写常驻进程(见 `AGENTS.common.md` §1.2);巡检只读,不改数据(修复留给用户或另一条命令)。
参考实现:`skills/x-social/scripts/xctl.mjs` 的 `cmdWatch`(多账号逐个验 + 逐个取凭据)、`skills/page-change-watch` 的 `notifyChanges`(值变化型推送)、`skills/disk-space-manager` 的 `push_tier_change`(档位变差才推 + 失败不写状态)。
## 18. 共享测试台(照抄就能用)
写测试不用从零搭:这两个台子已经在跑,直接抄形状(原则见 `AGENTS.common.md` §1.4:协议文档 + 假实现 + 契约测试)。
| 台子 | 位置 | 抄它来测什么 |
|---|---|---|
| pty 测试台 | `skills/agent-shell/tests/ptykit.py` | 只有真终端里才会发生的路径:按键绑定、提示符重绘、行编辑、Ctrl+某键。`Term` 在 pty 里 spawn 真 shell 喂按键字节,再轮询渲染后的屏幕;`stub_skill()` 把技能拷进沙箱并把不稳定的一侧(这里的 AI 通道)换成 stub;状态全在沙箱里,不碰用户目录 |
| 假后端 + 契约测试 | `skills/agent-shell/tests/engine-contract.py` 和 `tests/fake-engines/` | 对接外部程序:假实现按真格式回话(`events/*.jsonl` 是真工具跑一遍录下来的原文),测试断言参数、事件解析、错误分类、状态台账;不连网络、不烧额度 |
几条踩过的经验(pty 这类测试特别容易假绿):
- **断言前先等**:pty 里没有「写完就能读」的时序保证,固定 `read(秒)` 之后断言会随机失败;用 `wait_until(pred, 秒)` 轮询(`wait_for(文本, 秒)` 是它的语法糖)。
- **断言要能认出「没发生」**:「再按一次回车不许重发」比「屏幕上出现了回答」可靠得多,后者可能匹配到输入回显。
- **stub 不能假装真链路**:真链路开关(如 `--with-model`)要有,但默认路径必须离线全绿;反过来,真链路至少手动跑一次,用来确认 stub 没跟真实世界脱节。
- **沙箱要盖全**:状态目录、配置路径、终端身份变量都得换掉,否则测试会写进用户自己的状态(ptykit 的 `Term` 已经在做)。
## 19. 长驻子进程:谁起的谁收,替身要自限
技能入口 spawn 的「不会自己结束、要靠调用方停」的进程都归这一类:录制器、临时代理、pty 里的交互进程、托管起来的外部程序。系统不会帮你——父进程一死,子进程只是被 reparent 给 launchd 继续跑。按它**为什么存在**分两类,要求不同:
| 形态 | 要求 | 例子 |
|---|---|---|
| 为这一次调用服务的 | 调用方一退出就收掉:正常路径按 PID 停,另加退出兜底(见下) | demo-publish 的录制器 |
| 有意常驻的守护 | 故意活得比调用方久,那就必须有生命周期命令(`status`/`stop`)+ 状态目录 | `syswatch start` 的 nohup 值守 |
**退出兜底怎么写**(Node 入口为例,参考实现 `skills/demo-publish/scripts/lib/record.mjs`):
- 起进程即登记进一个集合、`close` 时移出(别给已退出的 PID 留念,那会误杀复用后的新进程)。
- `process.on('exit')` 里逐个发停止信号:这条覆盖异常退出与显式 `process.exit()`。
- `SIGTERM`/`SIGHUP`:先停子进程,再 `removeAllListeners(信号)` + `process.kill(process.pid, 信号)` 保持默认退出语义(不接管就没人退出)。
- **SIGINT 不接管**:留给入口自己的优雅收尾(停录、还原现场、写状态);接管了就把好好的收尾路径变成硬退。
- `SIGKILL` 连父子都拦不住,所以别把「不会残留」全押在兜底上:真身要留一个 `pgrep` 能查到的名字,替身要自限。
**替身/假实现必须自限**:真实长驻进程的语义就是「等到信号才停」,替身照抄就成了「没人收尾就永远转」——测试进程被硬杀时实测残留过 3 天 18 小时,白占内存、CPU 和窗口。给个上限(`STUB_MAX_SECONDS` 这类,默认百来秒),到点自己退出。
**怎么验**:起真入口 → 等子进程起来 → `kill` 掉入口 → 断言子进程消失。子进程 pid 要确定性拿到(替身自己写 pid 文件最省事),别用 `pgrep` 猜;用例要写成「关掉修复就变红」的形状(参考 `skills/demo-publish/tests/e2e.test.mjs` 的 SIGTERM 与自限两条)。

View File

@ -0,0 +1,79 @@
---
platforms: [all]
name: skill-interface
description: 所有技能的统一切入点:列技能、看用法、转发执行,以及契约检查(skillcheck)与 PATH 入口注册。要跑任何技能命令、或核对技能是否满足接口契约时使用。
---
# 技能接口(skill-interface)
**一句话**:模型只记一个入口 `pi-skill`,永不拼脚本路径、不带解释器前缀;技能是否合规由 `skillcheck` 机器判定。
**用户手册**(各命令含义/参数/场景步骤/注意事项):[`references/user-guide.md`](references/user-guide.md)。设计与规范正文:[`REFERENCE.md`](REFERENCE.md)。
## 何时用
- 要执行任何技能的命令 → `pi-skill list` 找技能 → `pi-skill <技能> help` 看用法 → 执行
- 要核对技能是否满足契约(新技能提交前、同步后)→ `skillcheck`
- 新机器、入口丢失或别名报错 → `install.sh run`
- 要知道哪些技能/命令没人用、哪些接口老出错、模型有没有叫错技能名 → `pi-skill stats`
- 改/删某个技能或命令之前,要知道会影响谁 → `pi-skill impact <技能> [命令]`
- 要改接口(删命令/改命令名/降 tier/移别名)→ 先 `skill-contract diff` 看是不是破坏性变更,留兼容 shim 或改完依赖方,再更新基线
- 刚换机器/新同事上手/命令报 `error=config` → `pi-skill doctor` 看缺什么配置与依赖,按它给的命令补
- 要把某个技能分享给别人/搬到另一台机器 → `pi-skill pack <技能> --dry-run` 先看会带什么、有没有泄漏
## 入口
```bash
pi-skill list [过滤] [--json] # 技能目录:名字/分级/别名/命令/何时用
pi-skill <技能> help # 某技能完整用法(权威,来自技能自己的 help)
pi-skill <技能> <命令> [参数...] # 执行(输出与退出码原样透传)
pi-skill stats [技能] [--since 7d|all] [--json]
# 调用统计(默认 30d):调用量/失败率/错误码/从未被调用的技能与命令
pi-skill impact <技能> [命令] [--json]
# 影响面:谁硬依赖(脚本调用)/软引用(文档提及)+ 近 30d 调用量
pi-skill doctor [--json] # 体检:缺必需配置(给 setup 命令)/ 缺外部依赖(给装法)
pi-skill pack <技能> [--out <目录>] [--dry-run] [--json]
# 打包给他人:白名单 + 泄漏扫描(密钥/家目录/真实工单号)+ MANIFEST
```
运维(人/CI 用,不是模型路径):
```bash
skillcheck [技能...] [--json] [--static] # 契约检查(含接口面漂移),只报告
skill-contract [status|diff|update] # 契约基线:看漂移/逐条差异/更新基线(update 需 --apply)
skill-pack <技能> [--out <目录>] [--dry-run] [--json] # 同 pi-skill pack(运维别名)
skill-entry-install plan|run|status # PATH 入口注册与核对
```
## 失败 → 动作
| 现象 | 动作 |
|---|---|
| `error=usage 没有技能「X」` | `pi-skill list`(名字拼错或技能未安装) |
| 拒绝转发并列出 `[check] …` | `skillcheck <技能>` 按 fix 修 `interface.json` |
| `error=deps` | 补依赖;Node ≥22 / python3 / 平台工具 |
| `alias-missing` / `alias-drift` | `skill-entry-install run` |
| `contract-drift` 报接口面破坏性变化 | 先做兼容(旧名留 shim 返回 `error=usage hint=…新名`)或改完依赖方;确认后 `skill-contract update <技能> --apply` |
| `stats` 报“窗口内没有调用记录” | 正常用 `pi-skill` 后再看,或 `--since all`;直接跑别名(如 `cdpctl`)不经 `pi-skill`,不会被记录 |
| 技能命令报 `error=config` | 缺随人/随机器变化的值:跑 `pi-skill doctor` 看缺哪个键,按它给的 `setup --<键>` 命令补(或设 `PI_SKILL_<技能>_<键>`) |
| 技能命令报 `error=auth` | 跑该技能的 `login`/`setup` 命令(见它的 help) |
| 技能命令报 `error=notfound` | 跑该技能的 `list`/`status` 重新定位目标 |
| `pack` 报 `blocked: 泄漏扫描发现 N 处` | 按 `LEAK` 行改掉(密钥/真实家目录/真实工单号);确认是误报才 `--force` |
| 别人拿到包后卡住 | 包内 `MANIFEST.json` 的 `requires`/`config` 是清单;对方跑 `pi-skill doctor` 与 `pi-skill <技能> check` 会自己说要补什么 |
## 契约(模型只需记这五条)
1. **入口唯一**:`pi-skill <技能> …`;技能自己的 help 是唯一权威。
2. **输出有稳定字段**:结论行 + `key=value`(或稳定 JSON),字段名跨版本不变。
3. **失败有封闭错误码**:`error=<code>` + `hint=<可执行下一步>`;退出码 2 = 调用错误(读 help),其它非零 = 执行失败(看 `error=`)。
4. **改状态的命令先干跑**:破坏性命令默认预览,`--yes/--apply` 才真做;写完回读生效值。
5. **数据自证来源**:读取类命令给 `source=`/`as_of=`,不用陈旧缓存冒充事实。
细节、分级(T0–T3,其中 T2 另要求 status 给 liveness 证据)、场景覆盖与检查项见 [REFERENCE.md](REFERENCE.md)。
## 边界
- `pi-skill` 只做解析与转发,不含技能语义;**不切换工作目录**(技能必须与 cwd 无关)。
- 接口的唯一真源是各技能的 `interface.json`;PATH 入口由它生成,不手改。
- 每次 `pi-skill` 调用留一条**形状**记录到 `local/skills/skill-interface/invocations.jsonl`(技能/命令/参数个数与旗标名/退出码/`error=` 码/耗时/会话与模型),**不记参数值与输出正文**;`PI_SKILL_LOG=0` 关闭。它是接口演进的数据来源,不是审计日志。
- 具体技能怎么用、参数是什么,一律查该技能的 `help`,不要凭记忆拼参数。

View File

@ -0,0 +1 @@
0.1.0

View File

@ -0,0 +1,48 @@
{
"version": 1,
"skill": "skill-interface",
"tier": 1,
"platforms": [
"all"
],
"commands": {
"audit": {
"destructive": false
},
"doctor": {
"destructive": false
},
"help": {
"destructive": false
},
"impact": {
"destructive": false
},
"list": {
"destructive": false
},
"pack": {
"destructive": false
},
"stats": {
"destructive": false
}
},
"errors": [
"conflict",
"deps",
"usage"
],
"aliases": [
"pi-skill"
],
"adminAliases": [
"skill-audit",
"skill-contract",
"skill-entry-install",
"skill-pack",
"skill-release",
"skill-retire",
"skillcheck"
]
}

View File

@ -0,0 +1,126 @@
{
"summary": "所有技能的统一切入点:技能目录、用法转发、PATH 入口注册与契约检查",
"useWhen": "要调用任何技能的命令,或要核对技能是否满足接口契约时",
"tier": 1,
"requires": [
{
"kind": "binary",
"name": "tar",
"install": "macOS/Windows 10+ 自带",
"check": "tar --version",
"note": "pi-skill pack 生成 .tar.gz 用;没有也能出目录包"
}
],
"entry": {
"path": "scripts/pi-skill.mjs"
},
"aliases": [
"pi-skill"
],
"commands": [
{
"name": "list",
"summary": "列出技能:名字/分级/别名/命令/何时用"
},
{
"name": "help",
"summary": "pi-skill 自己的用法"
},
{
"name": "stats",
"summary": "调用统计:调用量/失败率/错误码/从未被调用的技能与命令(--since 7d|all,--json)"
},
{
"name": "impact",
"summary": "影响面:谁硬依赖(脚本调用)/软引用(文档提及)某技能或命令,含近 30d 调用量(--json)"
},
{
"name": "doctor",
"summary": "体检:哪些技能缺必需配置(给 setup 命令)/ 缺外部依赖(给装法),--json"
},
{
"name": "pack",
"summary": "打包单个技能给他人用:白名单 + 泄漏扫描(密钥/家目录/真实工单号)+ MANIFEST(--out,--json)"
},
{
"name": "audit",
"summary": "技能库巡检:路由异常/入口重叠/体量膨胀/重复实现/生命周期(只报告,定期跑)",
"destructive": false
}
],
"admin": [
{
"path": "scripts/skill-registry.mjs",
"summary": "共享原语:发现/校验/解析入口/生成 shim(库,不是 CLI)"
},
{
"path": "scripts/skill-deps.mjs",
"summary": "依赖图原语:静态反查/别名归一/环检测/影响面(库,不是 CLI)"
},
{
"path": "scripts/skill-config.mjs",
"summary": "配置契约原语:优先级解析/schema 校验/secret 打码/写回(库,不是 CLI)"
},
{
"path": "scripts/skill-pack.mjs",
"summary": "可分发契约:白名单打包/泄漏扫描/MANIFEST",
"alias": "skill-pack"
},
{
"path": "scripts/skill-contract.mjs",
"summary": "契约基线(contract.lock.json):生成/漂移检查/更新",
"alias": "skill-contract"
},
{
"path": "scripts/skill-invocation-log.mjs",
"summary": "调用留痕原语:记录形状/读取/汇总(库,不是 CLI)"
},
{
"path": "scripts/skillcheck.mjs",
"summary": "契约检查(提交前、同步后跑)",
"alias": "skillcheck"
},
{
"path": "scripts/skill-entry-install.mjs",
"summary": "注册/核对 PATH 入口",
"alias": "skill-entry-install"
},
{
"path": "scripts/install.sh",
"summary": "入口注册的 shell 包装"
},
{
"path": "scripts/skill-audit.mjs",
"summary": "技能库巡检:路由/重叠/膨胀/重复实现/生命周期",
"alias": "skill-audit"
},
{
"path": "scripts/skill-retire.mjs",
"summary": "技能退役:门禁(0 硬依赖 + 0 调用)→ 退役指路牌 + status=deprecated",
"alias": "skill-retire"
},
{
"path": "scripts/skill-state.mjs",
"summary": "状态文件版本化原语:_schemaVersion / 迁移 / 备份 / dry-run(库,不是 CLI)"
},
{
"path": "scripts/skill-release.mjs",
"alias": "skill-release",
"summary": "发布技能的两个出口:cli(<工具>-cli 仓,给所有人)/ package(<工具> Pi 包仓,pi install 一条命令装)/ list(清点)"
}
],
"errors": [
"usage",
"deps",
"conflict"
],
"probeUnknownCommand": true,
"helpListsCommands": true,
"publish": {
"package_repo": "https://gitea.vhkd.top/geekinney/skill-interface.git"
},
"catalog": {
"layer": "core",
"why": "别的技能建在它上面"
}
}

View File

@ -0,0 +1,351 @@
# 技能接口用户手册
> 面向**用技能的人**与**改技能的人**:日常敲哪条命令、每个参数什么意思、出事怎么查、怎么把技能交给别人。
> 设计与规范正文在 [`../REFERENCE.md`](../REFERENCE.md);本文件只讲"怎么用"。
> 一句话:技能库按软件工程管——**依赖可查、改动有闸、分享干净、巡检自动**。
---
## 1. 一分钟理解
三条事实,后面全部命令都是它们的展开:
1. 技能只有一个入口:`pi-skill <技能> <命令> [参数...]`。**永远不要拼脚本路径**(`node skills/xxx/scripts/yyy.mjs` 这种写法会在换机器、改名、打包分发后失效)。
2. 技能 = 纯逻辑 + 声明 + 自检。它自己声明"我要什么"(依赖 / 配置 / 平台),缺什么会说,不需要你记。
3. 改技能的三条闸门:`skillcheck`(有没有违规)、`skill-contract`(接口有没有变)、`skillcheck --changed`(这次改动该跑谁的测试)。
你只需要记两组命令:**日常 4 条** + **开发 4 条**,其余按需查。
---
## 2. 日常命令(用技能的人)
### 2.1 `pi-skill list [过滤] [--json]` —— 我有哪些技能
```
pi-skill list # 全部(名字 / 分级 / 别名 / 何时用 / 命令数)
pi-skill list cdp # 名字或用途里含 cdp 的
pi-skill list --json # 机器可读(给脚本/模型用)
```
- `过滤`:对**技能名 + 用途 + 命令名**做匹配,可只写一个词。
- 名字前有 `!N` 表示它接口有 `N` 个问题,跑 `skillcheck <技能>` 看详情。
- 看不到某个技能:它在 `interface.json` 的 `platforms` 里声明不支持当前系统(如 Windows 专属技能在 Mac 上不列出)。
### 2.2 `pi-skill <技能> help` —— 这个技能怎么用
```
pi-skill reminder help
```
**用任何技能前先看它**:会自动列出全部命令、参数、示例、全局旗标。这是唯一权威用法来源,不要凭记忆或旧笔记调用。
各技能通用(是否支持看 help):
| 旗标 | 含义 |
|---|---|
| `--json` | 输出机器可读 JSON |
| `--dry-run` | 只预览,不落盘/不改外部系统 |
| `--yes` | 越过"默认只预览"的写入确认 |
### 2.3 `pi-skill <技能> <命令> [参数...]` —— 干活
```
pi-skill reminder add "提交 DMP" --at "明天10点"
pi-skill cdp-page-control nav https://example.com
pi-skill ssh-connect exec lightos "uptime"
```
输出与退出码**原样透传**(接口层不加任何包装、不引入额外等待)。看到 `error=` / `hint=` 就按 `hint` 做,它给的是可直接复制执行的下一步。
### 2.4 `pi-skill doctor [--json]` —— 这台机器还缺什么
```
pi-skill doctor
```
一台机器上手第一件事。它扫全部技能,把"缺必需配置 / 缺外部程序 / 需人工确认"列成待办,每条都带一条可执行命令:
```
setup xgf-openspec-query baseUrl 信工坊地址(SDD 开发单查询页)
修: pi-skill xgf-openspec-query setup --url <值>
manual non-turing-report account 域账号(git user.name) → 跑 non-turing-report check
ok 42 个技能当前无待办
字段: needs_setup=1 needs_install=0 needs_manual=1 skills_ok=42 skills_total=44
```
- `needs_setup=` 需要人给的值(照着 `修:` 敲)。
- `needs_install=` 缺的外部程序(给了安装命令)。
- `needs_manual=` 机器判不了、要人确认的(如账号、登录态)。
- 处理完重跑一次,直到 `needs_*=0`。**每一行都是真信号**——没配置的技能不会出现在这里,所以别忽略它。
### 2.5 `pi-skill stats [技能] [--since 7d|24h|2w|all] [--json]` —— 谁在用、哪里老失败
```
pi-skill stats --since 7d
pi-skill stats cdp-page-control --since all
```
- `[技能]`:只看一个技能的命令级明细;不写=全库汇总。
- `--since`:窗口,默认 `30d`。
- 用途:找"从来没人用的技能/命令"(该退役或合并)、"失败率高的命令"(接口设计有问题)、"错误码分布"(`notfound` 多 = 名字有歧义,`usage` 多 = 参数难记)。
```
stats — 2949 次调用 · 20 个技能 · 失败 211(7.2%)· 调用错误 46 since=2026-09-20
技能 调用 失败 常见错误
cdp-page-control 288 87 —
```
### 2.6 `pi-skill impact <技能> [命令] [--json]` —— 改之前看影响面
```
pi-skill impact cdp-page-control
pi-skill impact reminder time
```
```
impact cdp-page-control — 3 个技能在脚本里调用,3 个仅文档提及 · 近 30d 调用 288(失败 87)
hard article-clip commands=nav,html tierAtLeast=—
hard chatgpt-connectors commands=—
soft xgf-openspec-query 仅文档提及
```
- `hard`:真在脚本里调用(改名/删命令会让对方**运行时报错**,必须先改它们或用兼容窗口)。
- `soft`:只在文档/提示文案里提到(改了顺手更新即可)。
- 改任何被 `hard` 依赖的命令前,先跑这条命令拿到名单。
---
## 3. 开发与维护命令(改技能的人)
### 3.1 `skillcheck [技能...] [--static] [--quiet] [--json] [--changed [<ref>]]` —— 合规总闸
```
skillcheck # 全仓(含运行时探测:真跑每个 help)
skillcheck --static # 只静态检查(更快,不跑 help)
skillcheck reminder # 单个技能
skillcheck --quiet # 只打印有问题的项(干净时几乎无输出)
skillcheck --changed # 只看工作区改动(见 3.2)
```
- 退出码:`0` 通过;`1` 有 error;`2` 调用错误。
- 每条问题都带 `fix:` 可直接照做;`warn` 是信息性提示(如跨平台降级、外部工具干跑),`error` 必须处理才能提交。
- 它检查什么:`interface.json` 结构、文档路径、命令与 help 一致、依赖声明与脚本实际调用对账(`deps-*`)、配置声明对账(`config-*`)、契约漂移(`contract-*`)、退役残留(`deprecated-*`)、首用说明(`doc-first-use`)、读取类输出带 `source=`/`as_of=`(`external-source`)等。完整表见 REFERENCE §6。
### 3.2 `skillcheck --changed [<ref>]` —— 提交前的最短路径
```
skillcheck --changed # 对比工作区(改动还没提交)
skillcheck --changed origin/main # 对比分支(看这次分支改了哪些技能)
```
输出的是**待查清单**和**可直接粘的待跑命令**:
```
待查: reminder, apple-apps
待跑: node --test "skills/reminder/tests/*.test.mjs" "skills/apple-apps/tests/*.test.mjs"
skillcheck reminder apple-apps
```
- 自动带上**硬消费者**(依赖被改技能的其他技能)——这就是"改了小技能,谁来兜底"的答案。
- 软引用不会混进待跑清单,单列在 `mentioned` 里。
### 3.3 `skill-contract [status|diff|update] [技能...] [--all] [--apply] [--json]` —— 接口基线
```
skill-contract # = status:全仓漂移汇总
skill-contract diff reminder # 逐条看接口面变化(breaking / additive)
skill-contract update reminder --apply # 接受变化:重写基线并提交
```
- **基线 = `contract.lock.json`**,只记"接口面":命令集、错误码、tier、平台、别名。实现与文案改动不会误报。
- 删命令/改错误码/降 tier 会报 `drift`(error,`skillcheck` 也会报)→ 你必须显式决定:要么改回去,要么 `update --apply` 接受。基线文件是"这个技能对外的承诺",变化必须留痕。
- **不要手改 `contract.lock.json`**,用 `update --apply` 生成。
### 3.4 `pi-skill pack <技能> [--out <目录>] [--dry-run] [--json] [--force]` —— 打包给别人
```
pi-skill pack reminder
pi-skill pack reminder --out /tmp/give
```
```
pack reminder — 已打包 4 个文件(24.8 KB),版本 2239334
依赖技能:apple-apps(要一起给:pi-skill pack apple-apps)
tarball=/Users/you/.pi/agent/local/pack/reminder-2239334.tar.gz
字段: skill=reminder files=4 version=2239334 leaks=0 blocked=0
```
- 只装**白名单**内容(SKILL.md / REFERENCE.md / interface.json / contract.lock.json / scripts / assets / templates / references),`tests/`、本机 `config.json`、日志、缓存一律不进包。
- **泄漏扫描**:密钥字面量、绝对家目录、真实工单号 → 阻断(退出码 `3`);邮箱、内网域名 → 提示。`--force` 只越过泄漏门禁,不越过其它。
- `依赖技能:` 那行别忽略——**打包一个依赖别的技能的技能时,对方也要那份包**,否则他那边第一条命令就会撞上"依赖没装"。
- `版本` 来自 git(`git describe` 或短 commit),不手写版本号。
### 3.5 `pi-skill audit [--days 30] [--quiet] [--json]` —— 技能库巡检(只报告)
```
pi-skill audit # 人工巡检
pi-skill audit --quiet # 定时任务用:干净则零输出
```
五块报告:
| 块 | 看什么 | 典型动作 |
|---|---|---|
| 路由 | 调用记录里"不存在的技能名"、高频失败命令 | 改名字/加别名/改接口 |
| 重叠 | `useWhen` 词集高度相似的两个技能 | 合并或写清边界 |
| 膨胀 | 命令数 >12、SKILL.md >120 行、文档型却声明命令 | 拆技能 / 精简 |
| 重复实现 | 命令签名高度相似(D10:第三次出现才提取原语) | 观察,先别急 |
| 生命周期 | 近 N 天 0 调用 0 依赖的技能 | 考虑退役(见 3.6) |
**判定是启发式的**:它只把可疑处排到你眼前,是否要动由人复核(首次跑就抓出过真 bug,也把合法的单用途入口误报过一次,规则已收窄)。
### 3.6 `skill-retire <技能> --to <替代> [--days 30] [--force] [--apply] [--json]` —— 退役
```
skill-retire old-skill --to new-skill # 预览(默认不落盘)
skill-retire old-skill --to new-skill --apply # 真退役
```
- 两道门禁:①**0 硬依赖**(不可 `--force` 越过,否则等于把别人的脚本推下悬崖)②近 N 天 0 调用(可 `--force`)。
- 落盘结果 = **指路牌**,不是删除:老名字继续能调用,但只输出 `error=notfound 技能 <旧> 已退役,改用 <新>` + `hint=pi-skill <新> help`;SKILL.md 顶部加退役说明;契约基线同步。
- 落盘改动的是**技能自己的入口文件**(内容换成指路牌),PATH 别名不用重装;退役后跑一次 `skillcheck <旧技能>` 确认干净。
- 于是笔记/模型记忆里的老名字,会被引导到新技能,而不是变成"找不到"的哑谜。
### 3.7 `skill-entry-install plan|run|status [--json]` —— PATH 入口
```
skill-entry-install plan # 预览将写入的入口(默认不落盘)
skill-entry-install run # 写入/更新(幂等,安装器会自动调)
skill-entry-install status # 核对缺失/漂移/孤儿
```
**不要手写别名**。入口由各技能 `interface.json` 的 `aliases` 生成,改了声明就跑 `run`。
---
## 4. 输出与错误码怎么读
每个技能的输出都遵循同一形状,模型和人都能一眼判定:
```
结论行(一句话说清结果)
error=<码> <补充> ← 出事时才有;码是封闭集
hint=<可直接执行的下一步>
字段: key=value key=value ← 稳定字段行,机器可判
```
错误码封闭集:`usage`(参数/命令不对)· `deps`(缺外部程序)· `config`(缺随人变化的值)· `auth`(缺登录态)· `platform`(当前系统不支持)· `notfound`(技能/资源不存在)· `conflict`(状态冲突)· `blocked`(中途要人决策)· `timeout` · `external`(外部系统失败)· `internal`(技能自己的 bug)。
退出码统一约定:`0` 成功 · `2` 调用错误(`usage`/`config` 这类"你给的不对")· 其它非零 = 执行失败(看 `error=`)。**判断结果看 `error=` 与结论行,不要只看退出码数字**。
两个检查类命令另有:`skillcheck` 有 error 时退出 `1`;`skill-contract`/`skill-pack` 有待处理项时退出 `3`。
---
## 5. 场景手册(按这个顺序做就对了)
### A. 我要写一个新技能
1. 写 `skills/<新技能>/`:`interface.json`(声明面)+ `SKILL.md`(选路表:入口 + 失败→动作)+ `scripts/<入口>.mjs`。
2. 接口契约:`summary` / `useWhen` / `tier`(T0 只读 → T3 长时)/ `entry` / `commands` / `errors` / `dryRun`。
3. 需要别人先给的值(URL、账号、路径)→ 声明 `config[]`;需要外部程序/服务 → 声明 `requires[]`;调用别的技能 → 声明 `dependsOn[]`。
4. 给每个技能配 `check`(自检:缺什么、怎么补)——这是"别人拿到就能自己排查"的关键。
5. 跑 `skillcheck <新技能>` 到 `0 error`,跑 `skill-entry-install run` 注册别名。
6. 生成基线:`skill-contract update <新技能> --apply`。
7. 自测:`pi-skill <新技能> help`、`pi-skill <新技能> check`、以及至少一条真实命令。
8. 测试写在 `tests/*.test.mjs`(`node --test "skills/<新技能>/tests/*.test.mjs"` 可跑)。
### B. 我要改一个已有技能的接口
1. `pi-skill impact <技能>` → 拿到 `hard` 消费者名单。
2. 改代码。**删/改命令名、错误码、优先级这类"承诺"要慎重**:这是别人的调用点。
3. 改名或删除时留**兼容窗口**:老名字保留一条 shim,输出 `error=usage hint=<新名>`(而不是无声消失)。
4. `skillcheck <技能>` → `0 error`。
5. `skill-contract diff <技能>` 看接口面变化;确认无误后 `skill-contract update <技能> --apply`。
6. `skillcheck --changed` → 按"待跑"清单跑测试(含硬消费者的测试)。
7. 通知/修改 `hard` 消费者(或确认兼容窗口已兜住)。
### C. 换一台机器 / 新环境
1. 装好仓库与入口(安装器会跑 `skill-entry-install run`)。
2. `pi-skill doctor` → 照 `修:` / `装:` 逐条处理 → 重跑直到 `needs_*=0`。
3. 对要用的技能跑一次 `pi-skill <技能> check`(更细的自检:登录态、服务可达性)。
4. 之后按 2.x 的日常命令用即可。**随人变化的值不要写进技能目录**:放进 `~/.pi/agent/local/skills/<技能>/config.json`,或临时用环境变量 `PI_SKILL_<技能>_<键名>`(如 `PI_SKILL_REMINDER_REMOTE_HOST`)。
### D. 我要把技能分享给别人
1. `pi-skill pack <技能>` → 看输出里的**依赖技能**一行。
2. 有依赖就一起打:`pi-skill pack <依赖技能>`。
3. 把 `.tar.gz` 给对方(解包后顶层目录就是技能名,直接放进对方的 `<shared>/skills/`;被依赖的技能也要一起给)。
4. 对方三步:`pi-skill doctor`(缺什么它会说)→ 照 `修:` 配 → `pi-skill <技能> check`。
5. 想自己先验一遍:把包解到一个空目录,用空 `PI_CODING_AGENT_DIR` 跑 `help` / `check`,应当"能用 + 如实报告缺什么"。
### E. 我要退役一个技能
1. `pi-skill audit` 或 `pi-skill stats --since 30d` 确认它没人用。
2. `skill-retire <旧> --to <新>` 预览(默认不落盘)。
3. 门禁不过就先处理:改掉硬依赖方(不可 force),或确认调用量门禁可以 force。
4. `--apply` 落盘 → `skillcheck <旧>` → 把变更提交。
5. 老名字从此是"指路牌",笔记里的旧名字不会变成死链。
### F. 出问题时的排查顺序
| 症状 | 先跑 |
|---|---|
| 不知道怎么用 / 参数记不住 | `pi-skill <技能> help` |
| 说"没有技能 X" | `pi-skill list X`(是改名了?还是没装?→ `pi-skill doctor`) |
| 说缺值 / 缺程序 | `pi-skill doctor`、`pi-skill <技能> check` |
| 命令报 `error=usage` | `pi-skill <技能> help` 对照参数 |
| 最近老失败 | `pi-skill stats <技能> --since 7d` |
| 改了技能怕影响别人 | `pi-skill impact <技能>`、`skillcheck --changed` |
| 检查说接口变了 | `skill-contract diff <技能>` → 决定改回或 `update --apply` |
| 技能库整体不健康 | `pi-skill audit` |
### G. 保持它自动健康
- 已注册每天 09:30 的巡检:`pi-skill job-schedule status skill-audit`(`logs` 看历史;干净时零输出,所以"没消息"= 没问题)。
- 提交任何技能改动前:`skillcheck --changed`(最短闭环)。
- 新增周期任务一律走 `pi-skill job-schedule add <名> --cmd "<命令>" --at HH:MM|--every 30m`,不要手写 plist。
---
## 6. 注意事项(踩过的坑,逐条对应)
1. **不要拼脚本路径调用技能**,一律 `pi-skill`:路径会在换机器/改名/打包后失效,而且绕过依赖与配置检查。
2. **分享时别漏依赖**:`pi-skill pack` 报告里的"依赖技能"一行就是提醒;漏了对方只会看到"依赖没装"。
3. **`contract.lock.json` 是生成物**,不手改;用 `skill-contract update --apply`。
4. **私有数据不进共享仓库**:配置、状态、日志、缓存放 `~/.pi/agent/local/skills/<技能>/`(打包白名单天然不会带出)。
5. **破坏性命令默认预览**(`--dry-run` / 无 `--yes`)= 设计如此,不要为了"少敲一个参数"绕过确认。
6. **状态文件要带版本**:新技能的 `state.json` 用 `skill-state.mjs`(`_schemaVersion` + 迁移 + 备份 + 高版本拒写)。读到比代码更高的版本说明技能被回退过,宁可报错也别猜。
7. **声明宁多勿少**:模型(消费者)不会编译报错,只会静默用错。脚本里真调用了哪个技能/程序,就声明进去,`skillcheck` 会反查对账。
8. **常见问题早发现**:`skillcheck` 的 `warn` 不是"必须改",但要读懂它(例如 `deps-platform` 表示"依赖的技能只在别的平台可用",如果你已有降级路径就可以忽略)。
9. **多会话并行编辑同一目录时**:动手前 `git status --short` 看有没有别人的未提交改动,改完尽早只提交自己的路径。
10. **可疑就先看,别猜**:`pi-skill audit` / `skillcheck` / `impact` 三条命令能回答绝大多数"这个能不能动/是不是坏了"。
---
## 7. 速查表
```
# 日常
pi-skill list [过滤] 有哪些技能(!N = 有问题)
pi-skill <技能> help 怎么用(权威用法)
pi-skill <技能> <命令> [参数...] 干活
pi-skill doctor 这台机器缺什么(配置/依赖)
pi-skill stats [技能] [--since 7d] 谁在用、哪里老失败
pi-skill impact <技能> [命令] 改之前:谁会被影响
pi-skill audit [--quiet] 技能库巡检(只报告)
pi-skill pack <技能> 打包给别人(记得看"依赖技能")
# 开发
skillcheck [技能...] [--static] [--quiet] 合规总闸
skillcheck --changed [<ref>] 提交前最短路径
skill-contract [status|diff|update] [--apply] 接口基线
skill-entry-install plan|run|status 注册 PATH 入口
skill-retire <旧> --to <新> [--apply] 退役(默认预览)
# 读输出
结论行 / error=<码> / hint=<下一步> / 字段: k=v
退出码 0 成功 · 2 调用错误 · 其它看 error=(检查类 1=有 error,3=有待处理)
```

View File

@ -0,0 +1,29 @@
#!/bin/bash
# 注册技能 PATH 入口(幂等,可重复执行)。薄封装:只解析参数并转发给原语脚本。
set -euo pipefail
SELF_DIR="$(cd "$(dirname "$0")" && pwd)"
RUNNER="${PI_SKILL_NODE:-node}"
case "${1:-help}" in
help)
cat <<'EOF'
用法: install.sh plan|run|status [--json] | help
plan 预览将写入的 PATH 入口(不落盘)
run 写入 / 更新入口(幂等;非本工具生成的文件不覆盖)
status 核对缺失 / 漂移 / 孤儿
入口内容来自各技能 interface.json 的 aliases,写完后可直接用 pi-skill 或别名调用。
EOF
exit 0
;;
plan|run|status) ;;
*) echo "未知命令:$1;使用 help" >&2; exit 2 ;;
esac
if ! command -v "$RUNNER" >/dev/null 2>&1; then
echo "error=deps 找不到 $RUNNER" >&2
echo "hint=安装 Node.js ≥22 后重试(WSL 里没有 node 时改用 Windows 侧 PowerShell 执行)" >&2
exit 2
fi
exec "$RUNNER" "$SELF_DIR/skill-entry-install.mjs" "$@"

View File

@ -0,0 +1,78 @@
// 参数解析的唯一实现:全局旗标同名同义;未知旗标一律 usage 错误(不许静默忽略)。
// 原先是 telegram / apple-apps / demo-publish / x-social 各一份同源副本,已提取到这里;
// 各技能目录下的 lib/args.mjs 只是薄包装(负责自己的技能名,出现在提示里)。
import { fail } from './render.mjs';
export const GLOBAL_FLAGS = { json: 'bool', 'dry-run': 'bool', yes: 'bool', quiet: 'bool', timeout: 'int', help: 'bool' };
/**
* @param {string[]} argv
* @param {Record<string, 'bool'|'string'|'int'|'float'|'list'>} spec 命令自己的旗标
* @param {{skill?: string, command?: string}} where 只用于报错提示:pi-skill <skill> help…
*/
export function parseFlags(argv, spec = {}, { skill = '', command = '' } = {}) {
const defs = { ...GLOBAL_FLAGS, ...spec };
const helpHint = skill ? `pi-skill ${skill} help${command ? `(看 ${command} 用法)` : ''}` : 'pi-skill help';
const flags = {};
const positional = [];
for (let index = 0; index < argv.length; index += 1) {
const token = argv[index];
if (token === '--') { positional.push(...argv.slice(index + 1)); break; }
if (!token.startsWith('-') || token === '-') { positional.push(token); continue; }
if (token === '-h') { flags.help = true; continue; }
const eq = token.indexOf('=');
const name = (eq >= 0 ? token.slice(0, eq) : token).replace(/^--?/, '');
if (!Object.hasOwn(defs, name)) fail('usage', `未知参数 ${token}`, helpHint);
const type = defs[name];
if (type === 'bool') {
if (eq >= 0) fail('usage', `${token} 不接受取值`, helpHint);
flags[name] = true;
continue;
}
const raw = eq >= 0 ? token.slice(eq + 1) : argv[++index];
if (raw === undefined) fail('usage', `${token} 缺少取值`, helpHint);
if (type === 'int' || type === 'float') {
const pattern = type === 'int' ? /^-?\d+$/ : /^-?\d+(\.\d+)?$/;
if (!pattern.test(raw)) fail('usage', `${token} 需要${type === 'int' ? '整数' : '数字'},收到 ${raw}`, helpHint);
flags[name] = Number(raw);
} else if (type === 'list') {
flags[name] = [...(flags[name] ?? []), raw];
} else {
flags[name] = raw;
}
}
return { flags, positional };
}
export function requireFlag(flags, name, command, skill = '') {
if (flags[name] === undefined || flags[name] === '') {
fail('usage', `缺少必填参数 --${name}`, skill ? `pi-skill ${skill} help(${command})` : `看 ${command} 的用法`);
}
return flags[name];
}
export function timeoutMsOf(flags, fallbackSeconds = 30, envVar = '') {
const fromEnv = envVar && process.env[envVar] ? Number(process.env[envVar]) : undefined;
const seconds = flags.timeout ?? fromEnv ?? fallbackSeconds;
return Math.max(1, seconds) * 1000;
}
/** 位置参数拼成一段文本:`post 你好 世界` → "你好 世界"。 */
export function positionalText(positional) {
return positional.join(' ').trim();
}
/** 入口只扫它认识的全局旗标(--timeout 是唯一带值的),用来在错误时也能给出 --json 形式的输出。 */
export function scanGlobals(argv) {
const flags = {};
for (let index = 0; index < argv.length; index += 1) {
const token = argv[index];
if (token === '--json') flags.json = true;
else if (token === '--quiet') flags.quiet = true;
else if (token === '--dry-run') flags['dry-run'] = true;
else if (token === '--yes') flags.yes = true;
else if (token === '--timeout') { const value = argv[++index]; if (value !== undefined && /^\d+$/.test(value)) flags.timeout = Number(value); }
else if (token.startsWith('--timeout=')) { const value = token.slice('--timeout='.length); if (/^\d+$/.test(value)) flags.timeout = Number(value); }
}
return flags;
}

View File

@ -0,0 +1,64 @@
// 重复提交闸的唯一实现:同一操作者 + 同一目标 + 同一内容,在时间窗内出现过就报出来。
// 挡的是「传输抖动 / 模型重试 / 手滑连点」造成的重复对外动作,不是频率限制。
// 使用者:x-social(发帖/回复/引用/连发,mode=block)、telegram(发消息,mode=warn)。
//
// 落盘格式:JSON 数组,每条 { hash, action, target, who, id, url, at },只留窗口内的(默认 24h),最多 max 条。
import { createHash } from 'node:crypto';
import { chmodSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import path from 'node:path';
import { fail } from './render.mjs';
const DAY_MS = 24 * 60 * 60 * 1000;
/** 内容指纹:空白归一后取 sha1 前 16 位(够用且人可读)。 */
export function textHash(text) {
return createHash('sha1').update(String(text ?? '').replace(/\s+/g, ' ').trim()).digest('hex').slice(0, 16);
}
export function loadLog(file, { windowMs = DAY_MS, max = 50 } = {}) {
let entries = [];
try { entries = JSON.parse(readFileSync(file, 'utf8')); } catch { entries = []; }
if (!Array.isArray(entries)) return [];
const now = Date.now();
return entries
.filter(item => item && item.hash && item.at && now - Date.parse(item.at) < windowMs)
.slice(-max);
}
export function appendLog(file, entry, { windowMs = DAY_MS, max = 50 } = {}) {
const entries = [...loadLog(file, { windowMs, max }), { ...entry, at: entry.at ?? new Date().toISOString() }].slice(-max);
mkdirSync(path.dirname(file), { recursive: true });
writeFileSync(file, `${JSON.stringify(entries, null, 2)}\n`, { mode: 0o600 });
try { chmodSync(file, 0o600); } catch { /* 尽力而为 */ }
return entries.length;
}
/**
* @param {object} options
* @param {string} options.file 台账文件
* @param {string} options.action 动作名(post/reply/thread/send…)
* @param {string} options.text 内容
* @param {string} [options.target] 目标(帖子 id / 会话 id),空串表示无目标
* @param {string} [options.who] 操作者(账号 handle),空串表示不分账号
* @param {boolean} [options.again] 用户显式要求重复(--again),直接放行
* @param {'block'|'warn'} [options.mode] block=命中就 fail(默认);warn=照做,把命中项交回调用方
* @param {number} [options.windowMs] 时间窗,默认 10 分钟
* @returns {{hash: string, hit: object|null}}
*/
export function guardDuplicate({
file, action, text, target = '', who = '', again = false,
mode = 'block', windowMs = 10 * 60 * 1000, what = '内容', hint = '',
}) {
const hash = textHash(text);
if (again) return { hash, hit: null };
const hit = loadLog(file, { windowMs: DAY_MS }).find(item => item.hash === hash
&& item.action === action && (item.target ?? '') === target && (item.who ?? '') === who
&& Date.now() - Date.parse(item.at) < windowMs) ?? null;
if (!hit) return { hash, hit: null };
const link = hit.url ? `(${hit.url})` : '';
const message = `${Math.round(windowMs / 60000)} 分钟内${who ? `用 @${who} ` : ''}发过一模一样的${what}${link}`;
if (mode === 'block') {
fail('conflict', message, hint || '确实要再发一遍就加 --again,或者改几个字');
}
return { hash, hit, note: message };
}

View File

@ -0,0 +1,35 @@
// 推手机的唯一实现:调共享通知扩展(Telegram),收 {sent, reason},从不抛异常。
// 使用者:page-change-watch、x-social 的巡检。
// 约定:**只在异常时推**,正常静默——所以这里不做去重/限流,调用方负责只在有事时调它。
import { existsSync } from 'node:fs';
import path from 'node:path';
import process from 'node:process';
import { spawnSync } from 'node:child_process';
export function agentDirOf() {
return process.env.PI_CODING_AGENT_DIR || path.join(process.env.HOME, '.pi', 'agent');
}
export function notifyCliPath(agentDir = agentDirOf()) {
return path.join(agentDir, 'shared', 'extensions', 'notifications', 'cli.mjs');
}
/**
* @param {{title: string, body: string, group?: string, agentDir?: string, cli?: string, timeoutMs?: number}} message
* @returns {{sent: boolean, reason?: string}}
*/
export function notify({ title, body, group = '', agentDir = agentDirOf(), cli = '', timeoutMs = 30000 }) {
return runNotify(cli || notifyCliPath(agentDir), { title, body, group, timeoutMs });
}
function runNotify(cli, { title, body, group, timeoutMs }) {
if (!existsSync(cli)) return { sent: false, reason: `推送入口不存在: ${cli}` };
const result = spawnSync(process.execPath, [cli, 'send'], {
input: JSON.stringify({ title, body: String(body).slice(0, 2900), ...(group ? { group } : {}) }),
encoding: 'utf8',
timeout: timeoutMs,
});
return result.status === 0
? { sent: true }
: { sent: false, reason: (result.stderr || result.stdout || '推送失败').trim().slice(0, 200) };
}

View File

@ -0,0 +1,110 @@
// 输出与错误契约的唯一实现:首行结论 + 稳定 k=v(或 --json 等价 JSON),错误按封闭错误码。
// 原先是 telegram / apple-apps / demo-publish / x-social 各一份同源副本,已提取到这里;
// 各技能目录下的 lib/render.mjs 只是薄包装(负责自己的 source 默认值),调用方不用改。
//
// 退出码约定:2 = 用法错(调用方看 help 就能修),3 = 执行失败。
// 注意:apple-apps 的错误模型不同(每个错误码一个退出码,见它的 osascript.mjs),
// 所以它保留自己那份;这里的 EXIT 是其余技能的共同约定。
import process from 'node:process';
export const EXIT = { usage: 2, fail: 3 };
export const ERROR_CODES = ['usage', 'deps', 'platform', 'auth', 'config', 'notfound', 'conflict', 'blocked', 'timeout', 'external', 'internal'];
export class SkillError extends Error {
constructor(code, message, { hint = '', needHuman = false, asks = [], extraLines = [] } = {}) {
super(message);
this.code = code;
this.hint = hint;
this.needHuman = needHuman;
this.asks = asks;
this.extraLines = extraLines;
}
}
export function fail(code, message, hint = '', extra = {}) {
throw new SkillError(code, message, { hint, ...extra });
}
/** 读取类输出统一带 as_of:只留到秒的 UTC ISO(机器可判、人不困惑)。 */
export function nowIso() {
return `${new Date().toISOString().slice(0, 19)}Z`;
}
/** 值 → key=value 里的字符串(单行,空值给 "-")。 */
export function cell(value) {
if (value === undefined || value === null || value === '') return '-';
if (value === true || value === false) return String(value);
return String(value).replace(/[\r\n\t]+/g, ' ').trim();
}
function coerce(value) {
if (value === 'true') return true;
if (value === 'false') return false;
if (/^-?\d+$/.test(String(value)) && String(value).length < 12) return Number(value);
return value;
}
/**
* 打印结果:{ conclusion, fields, items, text, json, source, asOf }
* human: 结论行 → k=v 行 → items 行 → text 块 → source= / as_of=
* source 缺省从 ctx 里取(各技能在入口把它设成自己的来源),再不行才留空。
*/
export function emit(report, { json = false, quiet = false, source = '' } = {}) {
const { conclusion = '', fields = {}, items = [], text = '', json: rich = {}, asOf = '' } = report;
const origin = report.source ?? source ?? '';
const payload = {
ok: true,
conclusion,
...Object.fromEntries(Object.entries(fields)
.filter(([, value]) => value !== undefined && value !== null && value !== '')
.map(([key, value]) => [key, coerce(String(value))])),
...(items.length ? { items } : {}),
...(rich ?? {}),
source: origin,
...(asOf ? { as_of: asOf } : {}),
};
if (json) {
console.log(JSON.stringify(payload, null, 2));
return;
}
if (quiet) return;
if (conclusion) console.log(conclusion);
for (const [key, value] of Object.entries(fields)) {
if (value === undefined || value === null || value === '') continue;
console.log(`${key}=${cell(value)}`);
}
for (const item of items) console.log(item);
if (text) console.log(text);
console.log(`source=${origin}`);
if (asOf) console.log(`as_of=${asOf}`);
}
/** 错误 → 封闭错误码 + 可执行 hint;人读走 stderr,--json 走 stdout。 */
export function reportError(error, { json = false } = {}) {
const code = error instanceof SkillError ? error.code : 'internal';
const message = error?.message ?? String(error);
const hint = error?.hint ?? '';
const needHuman = error?.needHuman === true;
const asks = (error?.asks ?? []).filter(Boolean);
const extraLines = (error?.extraLines ?? []).filter(Boolean);
if (json) {
console.log(JSON.stringify({
ok: false,
error: code,
message,
...(asks.length ? { ask: asks } : {}),
...(hint ? { hint } : {}),
...(needHuman ? { need: 'human' } : {}),
...(extraLines.length ? { detail: extraLines } : {}),
}, null, 2));
} else {
console.error(`error=${code} ${message}`);
for (const ask of asks) console.error(`ask="${ask}"`);
for (const line of extraLines) console.error(line);
if (hint) console.error(`hint=${hint}`);
if (needHuman) console.error('need=human');
}
process.exitCode = code === 'usage' ? EXIT.usage : EXIT.fail;
// 同时设好退出码并返回它:有的入口写 process.exit(reportError(...)),有的写 process.exitCode = reportError(...)。
return process.exitCode;
}

View File

@ -0,0 +1,354 @@
#!/usr/bin/env node
/**
* pi-skill — 所有技能的统一切入点(模型只记这一个命令,永不拼路径、不带解释器前缀)。
*
* 用法:
* pi-skill list [过滤] [--json] 技能目录:名字 / 分级 / 入口别名 / 命令 / 何时用
* pi-skill <技能> help 该技能完整用法(原样转发它自己的 help,唯一权威)
* pi-skill <技能> [命令] [参数...] 转发执行(stdin/stdout/stderr 与退出码原样透传)
* pi-skill stats [技能] [--since 7d] [--json]
* 调用统计:调用量 / 失败率 / 错误码 / 从未被调用的技能与命令
* pi-skill help 本用法
*
* 退出码:0 成功;2 调用错误(技能名/接口不对,读 help);其它非零 = 执行失败,看输出里的 error= 字段。
* 本脚本只做解析与转发,不含任何技能语义;也不切换工作目录(技能必须与 cwd 无关)。
* 每次调用留一条形状记录(技能/命令/参数个数与旗标名/退出码/error 码/耗时;不记参数值与输出正文),
* 供 stats 回答「哪些原语没人用、哪些接口老出错」。PI_SKILL_LOG=0 关闭。
*/
import { loadSkills, runEntry } from './skill-registry.mjs';
import { consumersOf, dependencyGraph } from './skill-deps.mjs';
import { agentDirOf, configErrorLines, resolveConfig } from './skill-config.mjs';
import { ago, appendInvocation, buildRecord, logPath, parseSince, readInvocations, summarize } from './skill-invocation-log.mjs';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const USAGE = `pi-skill — 技能统一切入点
pi-skill list [过滤] [--json] 列出技能(名字 / 分级 / 别名 / 命令 / 何时用)
pi-skill <技能> help 该技能完整用法
pi-skill <技能> [命令] [参数...] 执行该技能(输出与退出码原样透传)
pi-skill stats [技能] [--since 7d|24h|2w|all] [--json]
调用统计(默认最近 30d):调用量 / 失败率 / 错误码 / 从未被调用的技能与命令
pi-skill impact <技能> [命令] [--json]
影响面:谁在脚本里真实调用它(hard)/ 谁只在文档提及(soft)+ 近 30d 调用量
pi-skill doctor [--json] 体检:哪些技能缺必需配置(给一条 setup 命令)/ 缺外部依赖(给装法)
pi-skill pack <技能> [--out <目录>] [--dry-run] [--json]
打包单个技能给他人用:白名单 + 泄漏扫描(密钥/家目录/真实工单号)+ MANIFEST
pi-skill audit [--days 30] [--quiet] [--json]
技能库巡检:路由异常 / 入口重叠 / 体量膨胀 / 重复实现 / 生命周期(建议定期跑)
pi-skill help 本用法
技能名与命令名可用 "pi-skill list" 查;不要直接拼脚本路径。`;
const startedAt = Date.now();
/** 留痕:任何路径退出前调一次;失败静默,不影响主流程。 */
const record = fields => appendInvocation(buildRecord({ startedAt, ms: Date.now() - startedAt, ...fields }));
// 下游提前关闭管道(如 `| head`)时安静退出,而不是抛 EPIPE 堆栈。
for (const stream of [process.stdout, process.stderr]) stream.on('error', error => { if (error.code === 'EPIPE') process.exit(0); });
// process.exit() drops output still queued for a pipe (async on macOS): drain stdout/stderr first.
const exit = async code => {
await new Promise(resolve => process.stdout.write('', resolve));
await new Promise(resolve => process.stderr.write('', resolve));
process.exit(code);
};
const fail = (message, hint, log = {}) => {
const text = `error=usage ${message}${hint ? `\nhint=${hint}` : ''}`;
console.error(text);
record({ skill: log.skill ?? 'skill-interface', command: log.command ?? null, args: log.args ?? [], exit: 2, output: text, resolved: log.resolved });
process.exit(2);
};
const cut = (text, width) => (text.length > width ? `${text.slice(0, width - 1)}…` : text);
function renderList(skills, filter) {
const matched = skills.filter(skill => !filter || skill.name.includes(filter));
if (!matched.length) return `没有匹配「${filter}」的技能。`;
const lines = matched.map(skill => {
const declared = skill.declared ?? {};
const broken = skill.errors.length ? `!${skill.errors.length}` : ' ';
const alias = declared.aliases?.length ? declared.aliases[0] : '—';
const names = (declared.commands ?? []).map(command => command.name);
const commands = names.length ? names.slice(0, 6).join('/') + (names.length > 6 ? `/…+${names.length - 6}` : '') : skill.docOnly ? '(文档型)' : '(未声明命令)';
return `${broken} ${skill.name.padEnd(26)} T${declared.tier ?? '?'} ${alias.padEnd(16)} ${cut(declared.summary ?? '缺 interface.json', 46)} ${commands}`;
});
const broken = matched.filter(skill => skill.errors.length).length;
return [
`pi-skill — ${matched.length} 个技能(!N = 接口有问题,跑 skillcheck 看修复)`,
...lines,
broken ? `\n有 ${broken} 个技能接口不合规:skillcheck ${matched.filter(s => s.errors.length).map(s => s.name).join(' ')}` : '',
].filter(Boolean).join('\n');
}
function renderStats(summary, meta, target) {
const head = target ? `stats ${target}` : 'stats';
const rate = summary.total ? `${Math.round((summary.failures / summary.total) * 1000) / 10}%` : '—';
const window = `since=${meta.since === 'all' ? 'all' : meta.since.slice(0, 10)} as_of=${meta.as_of.slice(0, 16)}Z source=${meta.source}`;
if (!summary.total) {
return `${head} — 窗口内没有调用记录 ${window}\nhint=正常使用 pi-skill 后再看;或放宽窗口 pi-skill stats --since all`;
}
const errorsText = errors => Object.entries(errors).slice(0, 3).map(([code, n]) => `${code}×${n}`).join(' ') || '—';
const lines = [`${head} — ${summary.total} 次调用 · ${summary.skills.length} 个技能 · 失败 ${summary.failures}(${rate})· 调用错误 ${summary.usage_errors} ${window}`];
if (target) {
const row = summary.skills[0];
lines.push('', `${'命令'.padEnd(22)} ${'调用'.padStart(5)} ${'失败'.padStart(5)} ${'常见错误'.padEnd(24)} 最近`);
for (const command of row?.commands ?? []) lines.push(`${command.name.padEnd(22)} ${String(command.calls).padStart(5)} ${String(command.failures).padStart(5)} ${errorsText(command.errors).padEnd(24)} ${ago(command.last)}`);
if (row?.never_called_commands.length) lines.push('', `从未调用的命令(${row.never_called_commands.length}):${row.never_called_commands.join(', ')}`);
} else {
lines.push('', `${'技能'.padEnd(26)} ${'调用'.padStart(5)} ${'失败'.padStart(5)} ${'常见错误'.padEnd(24)} 最近`);
for (const row of summary.skills) lines.push(`${row.name.padEnd(26)} ${String(row.calls).padStart(5)} ${String(row.failures).padStart(5)} ${errorsText(row.errors).padEnd(24)} ${ago(row.last)}`);
if (summary.never_called_skills.length) lines.push('', `从未被调用(${summary.never_called_skills.length}):${summary.never_called_skills.join(', ')}`);
const unknown = Object.entries(summary.unknown_skills);
if (unknown.length) lines.push(`调用了不存在的技能(${unknown.length}):${unknown.map(([name, n]) => `${name}×${n}`).join(', ')}`);
if (summary.top_failing_commands.length) {
lines.push('', '失败率最高的命令(≥3 次调用):');
for (const row of summary.top_failing_commands) lines.push(` ${row.skill} ${row.command} ${row.failures}/${row.calls}(${row.failure_rate}%) ${errorsText(row.errors)}`);
}
}
return lines.join('\n');
}
const [command, ...rest] = process.argv.slice(2);
const skills = loadSkills();
if (!command || command === 'help' || command === '-h' || command === '--help') {
console.log(USAGE);
console.log(`\n现有技能:${skills.map(skill => skill.name).join(', ')}`);
record({ skill: 'skill-interface', command: 'help', exit: 0 });
await exit(0);
}
if (command === 'impact') {
const positional = rest.filter(value => !value.startsWith('-'));
const target = positional[0];
const targetCommand = positional[1] ?? null;
if (!target) fail('impact 需要技能名', 'pi-skill impact <技能> [命令]', { command: 'impact', args: rest });
const skill = skills.find(candidate => candidate.name === target)
?? skills.find(candidate => (candidate.declared?.aliases ?? []).includes(target));
if (!skill) fail(`没有技能「${target}」`, 'pi-skill list', { command: 'impact', args: rest });
const graph = dependencyGraph(skills);
const consumers = consumersOf(graph, skill.name);
const summary = summarize(readInvocations({ since: parseSince('30d') }), { skills, skill: skill.name });
const row = summary.skills[0];
const commandDeclared = targetCommand ? (skill.declared?.commands ?? []).some(item => item.name === targetCommand) : null;
const declaredBy = name => graph.nodes.get(name)?.declared.get(skill.name) ?? null;
const calls = row?.calls ?? 0;
const failures = row?.failures ?? 0;
if (rest.includes('--json')) {
console.log(JSON.stringify({
ok: true,
skill: skill.name,
command: targetCommand,
command_declared: commandDeclared,
consumers_hard: consumers.hard,
consumers_soft: consumers.soft,
calls_30d: calls,
failures_30d: failures,
declared: [...consumers.hard, ...consumers.soft].map(name => ({ skill: name, hard: consumers.hard.includes(name), depends: declaredBy(name) })),
as_of: new Date().toISOString(),
}, null, 2));
} else {
const lines = [`impact ${skill.name}${targetCommand ? ` ${targetCommand}` : ''} — ${consumers.hard.length} 个技能在脚本里调用,${consumers.soft.length} 个仅文档提及 · 近 30d 调用 ${calls}(失败 ${failures})`];
for (const name of consumers.hard) {
const dep = declaredBy(name);
lines.push(`hard ${name.padEnd(26)} ${dep ? `commands=${(dep.commands ?? []).join(',') || '—'} tierAtLeast=${dep.tierAtLeast ?? '—'}` : '未声明(按 deps-undeclared 修)'}`);
}
for (const name of consumers.soft) lines.push(`soft ${name.padEnd(26)} 仅文档提及`);
if (targetCommand && !commandDeclared) lines.push(`注意: ${skill.name} 没有声明命令 ${targetCommand}`);
if (!consumers.hard.length && !consumers.soft.length) lines.push('没有任何技能引用它:改接口风险低,但删除前仍要看 pi-skill stats 的调用量');
lines.push('hint=改命令/删命令前先看影响面;改名或删除要先留 shim(ROADMAP Phase 2)');
lines.push(`字段: skill=${skill.name} consumers_hard=${consumers.hard.length} consumers_soft=${consumers.soft.length} calls_30d=${calls} failures_30d=${failures}`);
console.log(lines.join('\n'));
}
record({ skill: 'skill-interface', command: 'impact', args: rest, exit: 0 });
await exit(0);
}
if (command === 'doctor') {
const asJson = rest.includes('--json');
const agentDir = agentDirOf();
const hasBinary = name => (process.env.PATH || '').split(path.delimiter).some(dir => {
if (!dir) return false;
try { fs.accessSync(path.join(dir, name), fs.constants.X_OK); return true; } catch { return false; }
});
const platformEnabled = skill => skill.platforms.includes('all') || skill.platforms.includes(process.platform);
const needsSetup = [];
const needsInstall = [];
const needsManual = [];
const staleConfigs = [];
const enabled = skills.filter(skill => platformEnabled(skill));
for (const skill of enabled) {
const resolved = resolveConfig(skill, { agentDir });
const problems = [
...resolved.missing.map(item => ({ item, reason: resolved.fileError ? `配置文件读不了:${resolved.fileError}` : '未设置' })),
...resolved.invalid.map(entry => ({ item: entry.item, reason: entry.message })),
];
if (problems.length) needsSetup.push({
skill: skill.name, file: resolved.file, problems,
hints: configErrorLines(skill.name, problems).filter(line => line.startsWith('hint=')).map(line => line.slice(5).split(';或 ')).flat(),
});
for (const entry of resolved.stale) staleConfigs.push({ skill: skill.name, key: entry.item.key, updatedAt: entry.updatedAt, ttl: entry.item.ttl });
for (const require of skill.declared?.requires ?? []) {
if (require.kind === 'binary' || require.kind === 'runtime') {
if (!hasBinary(require.name)) needsInstall.push({ skill: skill.name, name: require.name, kind: require.kind, install: require.install ?? '', check: require.check ?? '' });
} else {
needsManual.push({ skill: skill.name, name: require.name, kind: require.kind, check: require.check ?? `pi-skill ${skill.name} check` });
}
}
}
const okCount = enabled.length - new Set([...needsSetup, ...needsInstall, ...needsManual].map(entry => entry.skill)).size;
if (asJson) {
console.log(JSON.stringify({ ok: true, needs_setup: needsSetup, needs_install: needsInstall, needs_manual: needsManual, stale_configs: staleConfigs, skills_ok: okCount, skills_total: enabled.length, as_of: new Date().toISOString() }, null, 2));
} else {
const lines = [`doctor — ${enabled.length} 个技能:${needsSetup.length} 个待设置配置,${needsInstall.length} 个缺外部依赖${needsManual.length ? `,${needsManual.length} 个需人工确认` : ''}`];
for (const entry of needsSetup) lines.push(`setup ${entry.skill.padEnd(24)} ${entry.problems.map(problem => `${problem.item.key}(${problem.reason})`).join('、')}\n 修: ${entry.hints.join('\n ')}`);
for (const entry of needsInstall) lines.push(`deps ${entry.skill.padEnd(24)} 缺 ${entry.name}${entry.install ? `\n 装: ${entry.install}` : ''}`);
for (const entry of needsManual) lines.push(`manual ${entry.skill.padEnd(24)} ${entry.kind} ${entry.name} → 跑 ${entry.check}`);
for (const entry of staleConfigs) lines.push(`stale ${entry.skill.padEnd(24)} ${entry.key}(${entry.ttl}s 前更新于 ${entry.updatedAt},建议重新确认)`);
lines.push(`ok ${okCount} 个技能当前无待办`);
if (needsSetup.length || needsInstall.length) lines.push('hint=按上面 修:/装: 的步骤处理完,重跑 pi-skill doctor 确认清零');
lines.push(`字段: needs_setup=${needsSetup.length} needs_install=${needsInstall.length} needs_manual=${needsManual.length} skills_ok=${okCount} skills_total=${enabled.length}`);
console.log(lines.join('\n'));
}
record({ skill: 'skill-interface', command: 'doctor', args: rest, exit: 0 });
await exit(0);
}
if (command === 'pack') {
const positional = rest.filter(value => !value.startsWith('-'));
const target = positional[0];
if (!target) fail('pack 需要技能名', 'pi-skill pack <技能> [--out <目录>] [--dry-run]', { command: 'pack', args: rest });
const skill = skills.find(candidate => candidate.name === target)
?? skills.find(candidate => (candidate.declared?.aliases ?? []).includes(target));
if (!skill) fail(`没有技能「${target}」`, 'pi-skill list', { command: 'pack', args: rest });
const { packReport, packSkill } = await import('./skill-pack.mjs');
const outIndex = rest.indexOf('--out');
const outDir = outIndex >= 0 && rest[outIndex + 1] ? path.resolve(rest[outIndex + 1]) : path.join(agentDirOf(), 'local', 'pack');
const result = packSkill(skill, { outDir, force: rest.includes('--force'), dryRun: rest.includes('--dry-run') });
if (rest.includes('--json')) console.log(JSON.stringify(result, null, 2));
else console.log(packReport(result, skill).join('\n'));
record({ skill: 'skill-interface', command: 'pack', args: rest, exit: result.ok ? 0 : 3 });
await exit(result.ok ? 0 : 3);
}
if (command === 'audit') {
const allowed = ['--json', '--quiet', '--days', 'help', '-h', '--help'];
const bad = rest.filter((value, index) => value.startsWith('-') && !allowed.includes(value) && rest[index - 1] !== '--days');
if (bad.length) fail(`audit 不认识的参数 ${bad.join(' ')}`, 'pi-skill audit [--days 30] [--quiet] [--json]', { command: 'audit', args: rest });
const { spawnSync } = await import('node:child_process');
const result = spawnSync(process.execPath, [path.join(__dirname, 'skill-audit.mjs'), ...rest], { stdio: 'inherit' });
record({ skill: 'skill-interface', command: 'audit', args: rest, exit: result.status ?? 1 });
await exit(result.status ?? 1);
}
if (command === 'stats') {
const positional = rest.filter((value, index) => !value.startsWith('--') && rest[index - 1] !== '--since');
const sinceIndex = rest.indexOf('--since');
const sinceRaw = sinceIndex >= 0 ? rest[sinceIndex + 1] : rest.find(value => value.startsWith('--since='))?.slice(8);
const since = parseSince(sinceRaw);
if (since === null) fail(`--since 只接受 7d / 24h / 2w / all,收到「${sinceRaw}」`, 'pi-skill stats --since 7d', { command: 'stats', args: rest });
const target = positional[0] ?? null;
if (target && !skills.some(skill => skill.name === target)) fail(`没有技能「${target}」`, 'pi-skill list', { command: 'stats', args: rest });
const summary = summarize(readInvocations({ since }), { skills, skill: target });
const meta = { source: logPath(), as_of: new Date().toISOString(), since: since ? new Date(since).toISOString() : 'all' };
if (rest.includes('--json')) {
console.log(JSON.stringify({ ok: true, skill: target, ...meta, ...summary }, null, 2));
} else {
console.log(renderStats(summary, meta, target));
}
record({ skill: 'skill-interface', command: 'stats', args: rest, exit: 0 });
await exit(0);
}
if (command === 'list') {
const filter = rest.find(value => !value.startsWith('--'));
if (rest.includes('--json')) {
console.log(JSON.stringify({
ok: true,
skills: skills.filter(skill => !filter || skill.name.includes(filter)).map(skill => ({
name: skill.name,
tier: skill.declared?.tier,
summary: skill.declared?.summary,
useWhen: skill.declared?.useWhen,
alias: skill.declared?.aliases?.[0],
entry: skill.declared?.entry,
docOnly: Boolean(skill.docOnly),
commands: (skill.declared?.commands ?? []).map(({ name, summary, destructive }) => ({ name, summary, destructive: destructive === true })),
errors: skill.errors.map(error => ({ check: error.check, message: error.message, fix: error.fix })),
})),
}, null, 2));
} else {
console.log(renderList(skills, filter));
}
record({ skill: 'skill-interface', command: 'list', args: rest, exit: 0 });
await exit(0);
}
const skill = skills.find(candidate => candidate.name === command)
?? skills.find(candidate => (candidate.declared?.aliases ?? []).includes(command));
if (!skill) {
const near = skills
.filter(candidate => candidate.name.includes(command.slice(0, 4)) || (candidate.declared?.aliases ?? []).some(alias => alias.includes(command.slice(0, 4))))
.map(candidate => candidate.name).slice(0, 5);
fail(`没有技能「${command}」`, near.length ? `pi-skill ${near[0]} help` : 'pi-skill list', { skill: command, command: rest[0] ?? null, args: rest.slice(1), resolved: false });
}
const [action, ...args] = rest;
if (skill.errors.length) {
console.error(`error=deps ${skill.name} 的接口不合规,拒绝转发:`);
for (const error of skill.errors) console.error(` [${error.check}] ${error.message}${error.fix ? ` → ${error.fix}` : ''}`);
console.error(`hint=skillcheck ${skill.name}`);
record({ skill: skill.name, command: action ?? null, args, exit: 2, output: 'error=deps' });
process.exit(2);
}
if (skill.docOnly) {
fail(`${skill.name} 是文档型技能(没有可执行入口)`, `按 ${skill.skillFile} 里的说明用其它技能的入口完成`, { skill: skill.name, command: action ?? null, args });
}
if (action === 'help' || action === '-h' || action === '--help') {
// `help <子命令>` 把子命令一并转发(大技能靠它给出命令级详情,例如 record/config)
const result = runEntry(skill.entry, ['help', ...args], { timeoutMs: 30_000 });
process.stdout.write(result.stdout);
process.stderr.write(result.stderr);
if (result.status !== 0) {
console.error(`error=usage ${skill.name} help 失败(退出码 ${result.status ?? 'none'})`);
console.error(`hint=按 ${skill.skillFile} 的说明调用;这是该技能的缺陷,请报告`);
record({ skill: skill.name, command: 'help', args, exit: 2, output: 'error=usage' });
await exit(2);
}
record({ skill: skill.name, command: 'help', args, exit: 0 });
await exit(0);
}
// 转发:stdin 直通;stderr 总是经过本进程(原样转写 + 截尾扫 error=);stdout 仅在非 TTY(模型/管道调用)时同样处理,
// 人在终端里交互式使用时 stdout 保持 inherit,行为与直接跑入口完全一致。
const forward = rest.length ? rest : ['help'];
const forwardCommand = rest.length ? action : 'help';
const { spawn } = await import('node:child_process');
const teeStdout = !process.stdout.isTTY;
let tail = '';
const keepTail = chunk => { tail = (tail + chunk.toString('utf8')).slice(-16_384); };
const child = spawn(skill.entry.argv[0], [...skill.entry.argv.slice(1), ...forward], {
stdio: ['inherit', teeStdout ? 'pipe' : 'inherit', 'pipe'],
env: process.env,
});
child.stderr.on('data', keepTail);
child.stderr.pipe(process.stderr, { end: false });
if (teeStdout) {
child.stdout.on('data', keepTail);
child.stdout.pipe(process.stdout, { end: false });
}
child.on('error', error => {
const text = `error=deps 无法启动 ${skill.entry.label}:${error.message}\nhint=pi-skill ${skill.name} help`;
console.error(text);
record({ skill: skill.name, command: forwardCommand, args, exit: 2, output: text });
process.exit(2);
});
child.on('close', async (code, signal) => {
const status = code ?? (signal ? 1 : 0);
record({ skill: skill.name, command: forwardCommand, args, exit: status, output: tail });
await exit(status);
});

View File

@ -0,0 +1,206 @@
#!/usr/bin/env node
/**
* skill-audit — 技能库巡检(只报告,不改动;人工复核与定期巡检用)。
*
* 用法:
* skill-audit [--json] [--days 30] [--quiet]
* --days N 调用数据窗口(默认 30 天)
* --quiet 只在有异常时输出(定期巡检用:干净就完全静默)
*
* 五块(对应 ROADMAP Phase 6):
* 1. 路由(P6.4) 调用日志里的「不存在的技能」与高频失败命令
* 2. 重叠(P6.4) useWhen/description 文本高度重叠的技能对(两份技能抢一个入口的信号)
* 3. 膨胀(P6.5) 命令数、SKILL.md 长度、退化的文档型技能
* 4. 重复实现(P6.6) 命令签名高度相似的两两技能(D10:第三次出现才提取共享原语)
* 5. 生命周期(P6.2) deprecated 但仍有硬消费者;从未被调用且无人依赖(可退役候选)
*
* 退出码:0 = 报告产生(异常不阻塞提交,只提示)。
*/
import fs from 'node:fs';
import path from 'node:path';
import { loadSkills, repoRoot, shimDir } from './skill-registry.mjs';
import { dependencyGraph, consumersOf } from './skill-deps.mjs';
import { parseSince, readInvocations, summarize, logPath } from './skill-invocation-log.mjs';
const USAGE = `skill-audit — 技能库巡检(只报告,不改动)
skill-audit [--json] [--days 30] [--quiet]
--days N 调用数据窗口,默认 30 天
--quiet 只在有异常时输出(定期巡检:干净则静默)
报告五块:路由异常 / 入口重叠 / 体量膨胀 / 重复实现候选 / 生命周期。
数据源:interface.json + 依赖图 + 调用留痕(${'<agent>/local/skills/skill-interface/invocations.jsonl'})。`;
const COMMAND_BLOAT = 12; // 单个技能声明命令数超过它 → 复核是不是该拆
const DOC_LINE_SOFT = 120; // SKILL.md 行数(软阈值,硬门禁是 skillcheck 的 150)
const OVERLAP_RATIO = 0.42; // useWhen/description 词集 Jaccard 超过它 → 复核
const DUPLICATE_RATIO = 0.6; // 命令名集合 Jaccard 超过它 → 重复实现候选
const MAX_ROWS = 8;
const args = process.argv.slice(2);
if (args.includes('help') || args.includes('-h') || args.includes('--help')) { console.log(USAGE); process.exit(0); }
const asJson = args.includes('--json');
const quiet = args.includes('--quiet');
const daysIndex = args.indexOf('--days');
const daysRaw = args[daysIndex + 1];
const unknown = args.filter((value, index) => value.startsWith('-') && !['--json', '--quiet'].includes(value) && !(value === '--days' || args[index - 1] === '--days'));
if (unknown.length) { console.error(`error=usage 未知参数 ${unknown.join(' ')}\nhint=skill-audit help`); process.exit(2); }
if (daysIndex !== -1 && !/^\d+$/.test(daysRaw ?? '')) { console.error('error=usage --days 只接受天数\nhint=skill-audit --days 30'); process.exit(2); }
const days = daysIndex === -1 ? 30 : Number(daysRaw);
const repo = repoRoot();
const loaded = loadSkills(repo).filter(skill => !skill.errors.length);
const graph = dependencyGraph(loaded);
/** 词集:按非字母数字/非 CJK 切分,长度 <2 的丢弃(中文按字切,英文按词)。 */
function terms(text) {
const tokens = String(text ?? '').toLowerCase().split(/[^a-z0-9\u4e00-\u9fff]+/).filter(token => token.length > 1);
const set = new Set(tokens);
for (const token of tokens) if (/^[\u4e00-\u9fff]{2,}$/.test(token)) for (let i = 0; i < token.length - 1; i += 1) set.add(token.slice(i, i + 2));
return set;
}
function jaccard(a, b) {
if (!a.size || !b.size) return 0;
let shared = 0;
for (const value of a) if (b.has(value)) shared += 1;
return shared / (a.size + b.size - shared);
}
// 1. 路由
const known = new Map(); // 调用名 → 技能名(含别名、admin 别名)
for (const skill of loaded) {
known.set(skill.name, skill.name);
for (const alias of skill.declared?.aliases ?? []) known.set(alias, skill.name);
for (const item of skill.admin ?? []) known.set(item.alias, skill.name);
}
const sinceMs = days === 0 ? 0 : Date.now() - days * 86400000;
let summary = null;
let logNote = 'no-log';
try {
const records = readInvocations({ since: sinceMs });
summary = summarize(records, { skills: loaded, minCalls: 3 });
logNote = records.length ? `since=${new Date(sinceMs).toISOString().slice(0, 10)}` : 'no-records';
} catch (error) {
logNote = `unreadable:${error.message}`;
}
/** 不是技能、但 PATH 上真有这个命令(如 pi-config)→ 外部工具,不算路由异常。 */
function externalCommand(name) {
if (name.includes('/') || name.includes(path.sep)) return false;
const dirs = [...(process.env.PATH ?? '').split(path.delimiter), shimDir()];
return dirs.some(dir => dir && fs.existsSync(path.join(dir, name)));
}
const routeIssues = Object.entries(summary?.unknown_skills ?? {})
.map(([name, count]) => {
const owner = known.get(name) ?? null;
const external = owner ? false : externalCommand(name);
return { name, count, known: owner, external };
})
.sort((a, b) => b.count - a.count);
const failing = summary?.top_failing_commands ?? [];
// 2. 重叠
const overlaps = [];
{
const entries = loaded.map(skill => ({ name: skill.name, set: terms(`${skill.declared?.useWhen ?? ''} ${skill.declared?.summary ?? ''}`) }));
for (let i = 0; i < entries.length; i += 1) {
for (let j = i + 1; j < entries.length; j += 1) {
const ratio = jaccard(entries[i].set, entries[j].set);
if (ratio >= OVERLAP_RATIO) overlaps.push({ a: entries[i].name, b: entries[j].name, ratio: Math.round(ratio * 100) / 100 });
}
}
overlaps.sort((x, y) => y.ratio - x.ratio);
}
// 3. 膨胀
const bloat = [];
for (const skill of loaded) {
const commands = (skill.declared?.commands ?? []).length;
const docLines = skill.markdown ? skill.markdown.split('\n') : [];
const lines = docLines.length - (docLines.at(-1) === '' ? 1 : 0); // 与 wc -l / skillcheck 一致
const documented = skill.markdown ? skill.markdown.includes(`pi-skill ${skill.name}`) : false;
const reasons = [];
if (commands > COMMAND_BLOAT) reasons.push(`命令 ${commands} 个`);
if (lines > DOC_LINE_SOFT) reasons.push(`SKILL.md ${lines} 行`);
if (skill.docOnly && commands) reasons.push('文档型却声明了命令');
if (!skill.docOnly && !commands && !documented) reasons.push('没有命令、也没在文档里给出调用形式');
if (reasons.length) bloat.push({ name: skill.name, reasons, commands, lines });
}
// 4. 重复实现候选
const duplicates = [];
{
const entries = loaded
.filter(skill => !skill.docOnly && (skill.declared?.commands ?? []).length >= 3)
.map(skill => ({ name: skill.name, set: new Set((skill.declared.commands ?? []).map(command => command.name)) }));
for (let i = 0; i < entries.length; i += 1) {
for (let j = i + 1; j < entries.length; j += 1) {
const ratio = jaccard(entries[i].set, entries[j].set);
if (ratio >= DUPLICATE_RATIO) duplicates.push({ a: entries[i].name, b: entries[j].name, ratio: Math.round(ratio * 100) / 100 });
}
}
duplicates.sort((x, y) => y.ratio - x.ratio);
}
// 5. 生命周期
const lifecycle = [];
for (const skill of loaded) {
const consumers = consumersOf(graph, skill.name);
const hard = consumers.hard;
if (skill.declared?.status === 'deprecated') {
lifecycle.push({ name: skill.name, kind: hard.length ? 'deprecated-with-consumers' : 'deprecated', detail: hard.length ? `仍被硬依赖:${hard.join(', ')}` : '没有硬依赖,可进入退役流程' });
continue;
}
const called = summary ? summary.skills.some(row => row.name === skill.name) : true;
if (!called && !hard.length && !consumers.soft.length && summary) lifecycle.push({ name: skill.name, kind: 'unused', detail: `${days} 天内 0 调用、0 依赖` });
}
const anomalies = routeIssues.filter(issue => !issue.known && !issue.external).length + overlaps.length + bloat.length + duplicates.length
+ lifecycle.filter(item => item.kind === 'deprecated-with-consumers' || item.kind === 'unused').length;
const report = {
skills: loaded.length,
log: logNote,
routes: routeIssues.filter(issue => !issue.known && !issue.external),
failing_commands: failing,
overlaps: overlaps.slice(0, MAX_ROWS),
bloat: bloat.slice(0, MAX_ROWS),
duplicates: duplicates.slice(0, MAX_ROWS),
lifecycle: lifecycle.slice(0, MAX_ROWS),
anomalies,
};
if (asJson) {
console.log(JSON.stringify(report, null, 2));
} else if (!quiet || anomalies) {
const lines = [`skill-audit — ${loaded.length} 个技能 · ${logNote} · ${anomalies} 条待复核`];
if (routeIssues.length) {
lines.push('', '路由:被调用的名字不在技能表里(模型猜错了入口,或别名没声明)');
for (const issue of routeIssues.slice(0, MAX_ROWS)) {
const note = issue.known ? ` → 其实是 ${issue.known}(别名已注册,可忽略)` : issue.external ? ' → PATH 上的外部命令,不是技能' : ' → 需要加别名或改提示文案';
lines.push(` ${issue.name}×${issue.count}${note}`);
}
}
if (failing.length) {
lines.push('', '高频失败命令(模型用得不对,或错误提示不够自解释)');
for (const row of failing) lines.push(` ${row.skill} ${row.command} ${row.failures}/${row.calls}(${row.failure_rate}%)${Object.keys(row.errors ?? {}).length ? ' ' + Object.entries(row.errors).map(([code, n]) => `${code}×${n}`).join(' ') : ''}`);
}
if (report.overlaps.length) {
lines.push('', '入口重叠(两份技能可能在抢同一个触发场景)');
for (const pair of report.overlaps) lines.push(` ${pair.a} ↔ ${pair.b} ${pair.ratio}`);
}
if (report.bloat.length) {
lines.push('', '体量(超过软阈值 = 复核是否该拆分,不是硬门禁)');
for (const row of report.bloat) lines.push(` ${row.name} ${row.reasons.join('、')}`);
}
if (report.duplicates.length) {
lines.push('', '重复实现候选(命令签名相似;D10:第三次出现才提取共享原语)');
for (const pair of report.duplicates) lines.push(` ${pair.a} ↔ ${pair.b} 命令集重合 ${pair.ratio}`);
}
if (report.lifecycle.length) {
lines.push('', '生命周期');
for (const row of report.lifecycle) lines.push(` ${row.name} ${row.kind} ${row.detail}`);
}
lines.push('', `字段: skills=${loaded.length} anomalies=${anomalies} routes=${report.routes.length} overlaps=${report.overlaps.length} bloat=${report.bloat.length} duplicates=${report.duplicates.length} lifecycle=${report.lifecycle.length} log=${logNote}`);
console.log(lines.join('\n'));
}
process.exit(0);

View File

@ -0,0 +1,200 @@
#!/usr/bin/env node
/**
* 配置契约原语(库,不是 CLI):随人/随机器变化的值统一走这里。
*
* 优先级:CLI 参数 > 环境变量 PI_SKILL_<SKILL>_<KEY> > local/skills/<技能>/config.json
* > 自动发现(技能自己查出来的值,传 discovered) > 声明里的 default
*
* 为什么:技能本体(共享仓库,可分发)只放确定性逻辑;凡是「每个人不一样」的值
* (主机名、代码根目录、账号、路径)都声明在 interface.json 的 config[],值存 local/(不进 Git)。
*
* 约定(写新技能/改造存量时照抄):
* - `config` 声明每个键:key/desc/type/required/requiredFor/secret/discover/ask/default/ttl/when
* - 缺必需值时输出 `error=config missing=<key> need=human ask="…" hint="pi-skill X setup --<key> <值>"`
* - secret: true 的键默认打码,只有 --reveal 才显示明文
* - 读取一律经本模块(不要自己拼 local/skills 路径),这样 doctor 能一次性体检全库
*
* 文件格式:`{ "<key>": <值>, …, "_meta": { "<key>": { "updatedAt": ISO } } }`
* (兼容存量技能的扁平 JSON:顶层非 `_meta` 的键就是值)
*/
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
export const CONFIG_SOURCES = ['arg', 'env', 'file', 'discovered', 'default'];
/** 声明里的 type 词汇表(与 skill-registry 共用同一个真源)。 */
export const CONFIG_TYPES = ['string', 'int', 'bool', 'path', 'host', 'url', 'enum', 'list'];
export const agentDirOf = () => process.env.PI_CODING_AGENT_DIR || path.join(os.homedir(), '.pi', 'agent');
export const configPathFor = (skillName, agentDir = agentDirOf()) => path.join(agentDir, 'local', 'skills', skillName, 'config.json');
export const envKeyOf = (skillName, key) => `PI_SKILL_${skillName.toUpperCase().replace(/-/g, '_')}_${key.replace(/([a-z0-9])([A-Z])/g, '$1_$2').toUpperCase()}`;
/** 读本机配置:文件不存在/坏掉都返回空对象(读取失败不该让技能崩),但把错误带出来。 */
export function readConfig(skillName, { agentDir } = {}) {
const file = configPathFor(skillName, agentDir);
try {
const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return { file, values: {}, meta: {}, error: '配置不是 JSON 对象' };
const { _meta: meta = {}, ...values } = parsed;
return { file, values, meta, error: null };
} catch (error) {
return { file, values: {}, meta: {}, error: error.code === 'ENOENT' ? null : error.message };
}
}
function platformMatches(item) {
const when = item?.when?.platform;
return !Array.isArray(when) || !when.length || when.includes(process.platform);
}
function typeError(item, value) {
const type = item.type ?? 'string';
if (type === 'list') return Array.isArray(value) ? null : '应为数组';
if (type === 'int') return Number.isInteger(Number(value)) && Number.isFinite(Number(value)) ? null : '应为整数';
if (type === 'bool') return typeof value === 'boolean' || value === 'true' || value === 'false' ? null : '应为布尔值';
if (type === 'enum') return Array.isArray(item.values) && item.values.includes(value) ? null : `只能是 ${(item.values ?? []).join(' / ')}`;
if (typeof value !== 'string') return '应为字符串';
if (type === 'host' && !/^[A-Za-z0-9._-]+(@[A-Za-z0-9._-]+)?$/.test(value)) return '应为 host 或 user@host';
if (type === 'path' && !value.trim()) return '路径不能为空';
if (type === 'url' && !/^https?:\/\//.test(value)) return '应为 http(s):// 开头的 URL';
if (Array.isArray(item.values) && !item.values.includes(value)) return `只能是 ${item.values.join(' / ')}`;
return null;
}
/**
* 解析一个技能的配置。
* @returns {{ ok: boolean, values: object, sources: object, missing: object[], invalid: object[], stale: object[], file: string, meta: object, fileError: string|null }}
*/
export function resolveConfig(skill, { argv = {}, env = process.env, discovered = {}, agentDir, now = Date.now() } = {}) {
const items = (skill.declared?.config ?? []).filter(platformMatches);
const { file, values: fileValues, meta, error: fileError } = readConfig(skill.name, { agentDir });
const values = {};
const sources = {};
const invalid = [];
const stale = [];
for (const item of items) {
const candidates = [
['arg', argv[item.key]],
['env', env[envKeyOf(skill.name, item.key)]],
['file', fileValues[item.key]],
['discovered', discovered[item.key]],
['default', item.default],
];
const hit = candidates.find(([, value]) => value !== undefined && value !== null && value !== '');
if (!hit) continue;
const [source, value] = hit;
const problem = typeError(item, value);
if (problem) { invalid.push({ item, value, message: problem }); continue; }
values[item.key] = value;
sources[item.key] = source;
const updatedAt = meta?.[item.key]?.updatedAt;
if (item.ttl && updatedAt && now - Date.parse(updatedAt) > item.ttl * 1000) stale.push({ item, value, updatedAt });
}
const missing = items.filter(item => item.required === true && !(item.key in values));
return { ok: !missing.length && !invalid.length, values, sources, missing, invalid, stale, file, meta, fileError };
}
/** 某条命令真正必需的配置(required 全量 ∪ requiredFor 命中该命令的)。 */
export function requiredForCommand(skill, command) {
return (skill.declared?.config ?? []).filter(item => platformMatches(item) && (item.required === true || (command && (item.requiredFor ?? []).includes(command))));
}
/** 缺配置时的标准输出行(error=config + 可执行 hint)。 */
export function configErrorLines(skillName, missing) {
const keys = missing.map(entry => entry.item ? entry.item.key : entry.key).join(' ');
const lines = [`error=config missing=${keys} need=human`];
for (const entry of missing) {
const item = entry.item ?? entry;
if (item.ask) lines.push(`ask="${item.ask}"`);
}
lines.push(`hint=${missing.map(entry => {
const item = entry.item ?? entry;
const flag = item.setupFlag || `--${item.key}`; // 技能的 setup 命令用了别的旗标时在 config[].setupFlag 里写明
return `pi-skill ${skillName} setup ${flag} <${item.type === 'list' ? '值1,值2' : '值'}>`;
}).join(';或 ')}`);
return lines;
}
/** secret 打码:保留首尾各 2 字符便于核对,中间固定 4 个 *。 */
export function maskValue(value) {
const text = String(value ?? '');
if (text.length <= 4) return '*'.repeat(text.length || 0) || '(空)';
return `${text.slice(0, 2)}****${text.slice(-2)}`;
}
/** 按声明决定要不要打码;`{ reveal: true }` 才显示明文。 */
export function displayValue(item, value, { reveal = false } = {}) {
if (value === undefined || value === null || value === '') return '';
if (!item?.secret || reveal) return Array.isArray(value) ? value.join(',') : String(value);
return maskValue(value);
}
/** 写回本机配置:合并 → 写 0600 → 回读并校验(写完的值必须能读出来)。 */
export function writeConfig(skillName, patch, { agentDir, now = new Date().toISOString() } = {}) {
const { file, values, meta } = readConfig(skillName, { agentDir });
const nextValues = { ...values };
const nextMeta = { ...meta };
for (const [key, value] of Object.entries(patch)) {
if (value === undefined || value === null || value === '') delete nextValues[key];
else nextValues[key] = value;
nextMeta[key] = { updatedAt: now };
}
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
fs.writeFileSync(file, `${JSON.stringify({ ...nextValues, _meta: nextMeta }, null, 2)}\n`, { mode: 0o600 });
const readBack = readConfig(skillName, { agentDir });
const keys = Object.keys(patch);
const ok = keys.every(key => (patch[key] === undefined || patch[key] === null || patch[key] === '' ? !(key in readBack.values) : JSON.stringify(readBack.values[key]) === JSON.stringify(patch[key])));
return { file, ok, values: readBack.values, meta: readBack.meta };
}
/** 体检用:声明了但脚本里从没出现过的键(防止声明烂掉;反向检查比正向扫描代码可靠,见 ROADMAP D14)。 */
export function declaredButUnused(skill, text) {
return (skill.declared?.config ?? []).filter(item => {
const forms = [item.key, item.key.replace(/-/g, '_'), item.key.replace(/[-_](\w)/g, (_, c) => c.toUpperCase()), envKeyOf(skill.name, item.key)];
return !forms.some(form => new RegExp(`\\b${form.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`).test(text));
});
}
/** 体检用:脚本里出现的 PI_SKILL_<SKILL>_<KEY> 环境变量键名(可用来抓未声明的键)。 */
export function envKeysUsed(skill, text) {
const prefix = `PI_SKILL_${skill.name.toUpperCase().replace(/-/g, '_')}_`;
const found = new Set();
for (const match of text.matchAll(new RegExp(`${prefix}[A-Z0-9_]+`, 'g'))) found.add(match[0].slice(prefix.length).toLowerCase());
return [...found];
}
/** 到处都有、不用声明的命令(或由运行时保证的):声明的价值为负。 */
const UBIQUITOUS_BINARIES = new Set([
'node', 'python', 'python3', 'bash', 'sh', 'zsh', 'fish', 'env', 'echo', 'printf', 'cat', 'sed', 'grep', 'awk',
'sleep', 'true', 'false', 'date', 'which', 'command', 'test', 'mkdir', 'rm', 'cp', 'mv', 'ln', 'ls', 'chmod',
'head', 'tail', 'wc', 'sort', 'uniq', 'tr', 'cut', 'tee', 'touch', 'dirname', 'basename', 'readlink', 'xargs',
'find', 'stat', 'uname', 'id', 'ps', 'kill', 'pkill', 'killall', 'open', 'defaults', 'plutil', 'launchctl',
'osascript', 'sw_vers', 'sysctl', 'df', 'du', 'git', 'curl', 'jq', 'sqlite3', 'sips', 'tccutil', 'timeout',
]);
const BINARY_PATTERNS = [
/spawnSync\(\s*['"]([A-Za-z0-9_.-]+)['"]/g,
/execFileSync\(\s*['"]([A-Za-z0-9_.-]+)['"]/g,
/shutil\.which\(\s*['"]([A-Za-z0-9_.-]+)['"]/g,
/subprocess\.run\(\s*\[\s*['"]([A-Za-z0-9_.-]+)['"]/g,
/command -v ([A-Za-z0-9_.-]+)/g,
];
/** 体检用:脚本里真会调用的外部二进制 → 用来抓「用了但没在 requires[] 里声明」。
* 排除:无处不在的命令、技能自己的名字/别名、其它技能的名字/别名(那是 dependsOn 的活)。 */
export function binariesUsed(skill, text, others = []) {
const declared = new Set((skill.declared?.requires ?? []).map(item => item.name));
const self = new Set([skill.name, ...(skill.declared?.aliases ?? [])]);
const foreign = new Set(others.flatMap(other => [other.name, ...(other.declared?.aliases ?? []), ...(other.declared?.admin ?? []).map(item => item.alias).filter(Boolean)]));
const found = new Set();
for (const pattern of BINARY_PATTERNS) {
for (const match of text.matchAll(pattern)) {
const name = match[1];
if (UBIQUITOUS_BINARIES.has(name) || self.has(name) || foreign.has(name) || declared.has(name)) continue;
found.add(name);
}
}
return [...found];
}

View File

@ -0,0 +1,182 @@
#!/usr/bin/env node
/**
* 契约基线原语:从 interface.json 推导「接口面」,生成与比对 contract.lock.json。
*
* 接口面 = 命令集(含 destructive 标记)、错误码、tier、platforms、别名(模型可见 + admin)。
* **不含实现**:入口文件内容、help 文案、summary 文本变化都不算接口变化——只有接口面变化才是漂移。
*
* 用途(见 ROADMAP.md D3):
* - `skill-contract status` 谁缺基线 / 谁漂移了(结论行 + 稳定字段)
* - `skill-contract diff` 逐条列出变化并分类:breaking(依赖方会坏)/ additive(只增不改)
* - `skill-contract update` 预览重写基线;`--apply` 落盘(生成物,随 Git 提交与回滚)
* skillcheck 的 `contract-drift` / `contract-additive` 检查复用本模块;更新基线是显式动作,
* 这样「接口变了」在提交时一定会被看见一次。
*
* 用法:
* skill-contract [status] [技能...] [--json]
* skill-contract diff [技能...] [--json]
* skill-contract update [技能...] [--all] [--apply] [--json]
*
* 退出码:0 无漂移/已应用;3 有漂移或待应用项(与 skill-entry-install 一致);2 调用错误。
*/
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { loadSkills, repoRoot } from './skill-registry.mjs';
export const CONTRACT_VERSION = 1;
export const contractPath = dir => path.join(dir, 'contract.lock.json');
/** 技能 → 接口面(稳定排序,保证 diff 只反映真实变化)。 */
export function contractOf(skill) {
const declared = skill.declared ?? {};
const commands = Object.fromEntries(
(declared.commands ?? []).map(command => [command.name, { destructive: command.destructive === true }])
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)),
);
return {
version: CONTRACT_VERSION,
skill: skill.name,
tier: declared.tier ?? null,
platforms: [...(skill.platforms ?? [])].sort(),
commands,
errors: [...(declared.errors ?? [])].sort(),
aliases: [...(declared.aliases ?? [])].sort(),
adminAliases: [...(declared.admin ?? []).map(item => item.alias).filter(Boolean)].sort(),
};
}
export function readContract(skill) {
try { return JSON.parse(fs.readFileSync(contractPath(skill.dir), 'utf8')); } catch { return undefined; }
}
/** 比对两份接口面:返回 [{ level: breaking|additive, kind, detail }]。 */
export function diffContract(locked, current) {
const changes = [];
const push = (level, kind, detail) => changes.push({ level, kind, detail });
for (const name of Object.keys(locked.commands ?? {})) {
if (!(name in (current.commands ?? {}))) push('breaking', 'command-removed', `命令被删除:${name}`);
else if (locked.commands[name].destructive !== current.commands[name].destructive) {
push('breaking', 'command-destructive', `命令 ${name} 的 destructive 标记变化:${locked.commands[name].destructive} → ${current.commands[name].destructive}`);
}
}
for (const name of Object.keys(current.commands ?? {})) if (!(name in (locked.commands ?? {}))) push('additive', 'command-added', `新增命令:${name}`);
for (const code of locked.errors ?? []) if (!(current.errors ?? []).includes(code)) push('breaking', 'error-removed', `错误码不再声明:${code}`);
for (const code of current.errors ?? []) if (!(locked.errors ?? []).includes(code)) push('additive', 'error-added', `新增错误码:${code}`);
if (locked.tier !== null && current.tier !== null && current.tier < locked.tier) push('breaking', 'tier-lowered', `分级下调:T${locked.tier} → T${current.tier}`);
if (locked.tier !== null && current.tier !== null && current.tier > locked.tier) push('additive', 'tier-raised', `分级上调:T${locked.tier} → T${current.tier}`);
for (const platform of locked.platforms ?? []) if (!(current.platforms ?? []).includes(platform)) push('breaking', 'platform-removed', `平台支持移除:${platform}`);
for (const platform of current.platforms ?? []) if (!(locked.platforms ?? []).includes(platform)) push('additive', 'platform-added', `平台支持新增:${platform}`);
for (const alias of locked.aliases ?? []) if (!(current.aliases ?? []).includes(alias)) push('breaking', 'alias-removed', `入口别名移除:${alias}`);
for (const alias of current.aliases ?? []) if (!(locked.aliases ?? []).includes(alias)) push('additive', 'alias-added', `入口别名新增:${alias}`);
for (const alias of locked.adminAliases ?? []) if (!(current.adminAliases ?? []).includes(alias)) push('breaking', 'admin-alias-removed', `运维别名移除:${alias}`);
for (const alias of current.adminAliases ?? []) if (!(locked.adminAliases ?? []).includes(alias)) push('additive', 'admin-alias-added', `运维别名新增:${alias}`);
return changes;
}
/** 单个技能的基线状态:ok / missing / drift / additive,附带变化明细。 */
export function contractStatus(skill) {
const locked = readContract(skill);
const current = contractOf(skill);
if (!locked) return { skill: skill.name, state: 'missing', changes: [], current };
const changes = diffContract(locked, current);
if (!changes.length) return { skill: skill.name, state: 'ok', changes, current };
return { skill: skill.name, state: changes.some(change => change.level === 'breaking') ? 'drift' : 'additive', changes, current };
}
export function writeContract(skill) {
fs.writeFileSync(contractPath(skill.dir), `${JSON.stringify(contractOf(skill), null, 2)}\n`);
}
const USAGE = `skill-contract — 契约基线(contract.lock.json)生成与漂移检查
skill-contract [status] [技能...] [--json] 缺基线 / 漂移 汇总(默认全部技能)
skill-contract diff [技能...] [--json] 逐条列出接口面变化(breaking/additive)
skill-contract update [技能...] [--all] [--apply] [--json]
预览重写基线;--apply 才落盘
接口面 = commands / errors / tier / platforms / aliases / admin aliases(不含实现与文案)。
退出码:0 无漂移;3 有漂移或有待应用项;2 调用错误。`;
function main() {
// 检查器自己发起的探测不是真实调用,不进 pi-skill 的调用留痕;只在 CLI 入口设置,不被 import 时污染调用方。
process.env.PI_SKILL_LOG = '0';
const args = process.argv.slice(2);
if (args.includes('help') || args.includes('-h') || args.includes('--help')) { console.log(USAGE); process.exit(0); }
const unknown = args.filter(value => value.startsWith('-') && !['--json', '--apply', '--all'].includes(value));
if (unknown.length) { console.error(`error=usage 未知参数 ${unknown.join(' ')}\nhint=skill-contract help`); process.exit(2); }
const asJson = args.includes('--json');
const apply = args.includes('--apply');
const all = args.includes('--all');
const command = args.find(value => !value.startsWith('-') && ['status', 'diff', 'update'].includes(value)) ?? 'status';
const positional = args.filter(value => !value.startsWith('-') && !['status', 'diff', 'update'].includes(value));
if (command === 'update' && !all && !positional.length) { console.error('error=usage update 需要技能名或 --all\nhint=skill-contract update --all --apply'); process.exit(2); }
if (command === 'diff' && !all && !positional.length) { console.error('error=usage diff 需要技能名或 --all\nhint=skill-contract diff --all'); process.exit(2); }
const skills = loadSkills(repoRoot()).filter(skill => skill.declared);
const selected = all ? skills : positional.length ? skills.filter(skill => positional.includes(skill.name) || (skill.declared?.aliases ?? []).some(alias => positional.includes(alias))) : skills;
if (!selected.length) { console.error(`error=notfound 没有匹配的技能:${positional.join(' ')}\nhint=pi-skill list`); process.exit(2); }
if (command === 'update') {
const pending = selected.map(skill => ({ skill, locked: readContract(skill), current: contractOf(skill) }));
if (asJson) {
console.log(JSON.stringify({ ok: true, applied: apply, skills: pending.map(item => ({ skill: item.skill.name, changes: item.locked ? diffContract(item.locked, item.current) : [{ level: 'additive', kind: 'contract-created', detail: '首次生成基线' }] })) }, null, 2));
} else {
const lines = [`skill-contract update — ${pending.length} 个技能${apply ? '' : '(预览,未落盘)'}`];
for (const item of pending) {
const changes = item.locked ? diffContract(item.locked, item.current) : [{ level: 'additive', kind: 'contract-created', detail: '首次生成基线' }];
if (!changes.length) continue;
lines.push(`${apply ? '写入' : '将写入'} ${item.skill.name}:${changes.map(change => change.detail).join(';')}`);
}
if (pending.every(item => item.locked && !diffContract(item.locked, item.current).length)) lines.push('全部已是最新,无需写入');
lines.push(`字段: skills=${pending.length} applied=${apply}`);
console.log(lines.join('\n'));
}
if (!apply) process.exit(3);
for (const item of pending) writeContract(item.skill);
if (asJson) console.log(JSON.stringify({ ok: true, applied: true, written: pending.length }));
else console.log(`已写入 ${pending.length} 个 contract.lock.json;记得随改动一起提交(Git diff 就是一份接口变更说明)`);
process.exit(0);
}
const statuses = selected.map(contractStatus);
const drifted = statuses.filter(item => item.state === 'drift');
const missing = statuses.filter(item => item.state === 'missing');
const additive = statuses.filter(item => item.state === 'additive');
if (command === 'diff') {
if (asJson) console.log(JSON.stringify({ ok: !drifted.length, drift: drifted.length, skills: statuses.filter(item => item.state !== 'ok') }, null, 2));
else {
const lines = [`skill-contract diff — ${statuses.length} 个技能:${drifted.length} 漂移 / ${additive.length} 只增 / ${missing.length} 缺基线`];
for (const item of [...drifted, ...additive, ...missing]) {
lines.push('', `${item.state === 'drift' ? 'ERROR' : 'warn '} ${item.skill}${item.state === 'missing' ? ':还没有 contract.lock.json' : ''}`);
for (const change of item.changes) lines.push(` ${change.level === 'breaking' ? 'breaking' : 'additive'} ${change.detail}`);
if (item.state === 'missing') lines.push(` fix: skill-contract update ${item.skill} --apply`);
else if (item.state === 'drift') lines.push(` fix: 先按依赖方需求做兼容处理(留 shim 或改依赖方),确认后 skill-contract update ${item.skill} --apply`);
}
console.log(lines.join('\n'));
}
process.exit(drifted.length ? 3 : 0);
}
if (asJson) {
console.log(JSON.stringify({ ok: !drifted.length && !missing.length, drift: drifted.length, missing: missing.length, additive: additive.length, skills: statuses, as_of: new Date().toISOString() }, null, 2));
} else {
const lines = [`skill-contract — ${statuses.length} 个技能:${drifted.length} 漂移 / ${additive.length} 只增 / ${missing.length} 缺基线`];
for (const item of [...drifted, ...missing, ...additive].slice(0, 20)) lines.push(` ${item.state.padEnd(9)} ${item.skill}`);
if (drifted.length) lines.push('', `修法:skill-contract diff ${drifted.map(item => item.skill).join(' ')}`);
if (missing.length) lines.push('', '补基线:skill-contract update --all --apply');
lines.push(`字段: drift=${drifted.length} missing=${missing.length} additive=${additive.length}`);
console.log(lines.join('\n'));
}
process.exit(drifted.length || missing.length ? 3 : 0);
}
if ((function () { try { return Boolean(process.argv[1]) && fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url)); } catch { return false; } })()) main();

View File

@ -0,0 +1,191 @@
#!/usr/bin/env node
/**
* 依赖图原语(库,不是 CLI;不经模型调用):
* 静态反查技能间引用 → 依赖图 → 环检测 → 影响面查询。
*
* 背景(见 ROADMAP.md D1–D5):技能库是滚动发行版,`pi-skill X` 永远解析到磁盘上唯一一份,
* 无法多版本共存;唯一可靠的防线是「让破坏性变更不可能悄悄发生」。这里做两件事:
* 1) 用静态反查得到依赖**事实**;
* 2) 与 interface.json 的 dependsOn **声明**互相对账(声明必须可被机器反查,否则会腐烂)。
*
* 边的分类:
* hard = 脚本(scripts/ 下非测试文件)里**真会执行**的 `pi-skill <目标>`(含两种写法)、或跨技能相对 import:
* ① 字面量:`pi-skill <目标>`(提示文案位于非 ASCII 前缀且不在命令替换里时降为 soft);
* ② 派生式:文件里把 `pi-skill` 当作字符串 spawn(`execFileSync('pi-skill', ['pi-settings-edit', …])`、
* `cmd = ["pi-skill", "macos-desktop-control"]`),目标名以带引号的字符串出现在同一文件 → hard。
* soft = ①文档(SKILL.md / REFERENCE.md)里的提及;②脚本里的**提示文案**(如 `error=deps` 时
* 告诉用户「Windows 上请用 pi-skill reminder」)——提示是给人/模型看的,不构成运行时依赖。
* 判定提示文案的启发式:同一行匹配点之前出现非 ASCII 字符(中文文案),且不在命令替换 `$()`/`` ` `` 内。
* 脚本里带 hint 的恢复路径(如 `hint=pi-skill X nav …`)仍算 hard:目标命令改名同样会让它失效。
* hard 命中优先,soft 只保留「仅文档提及 / 仅提示」的目标。
* 通用路由器命令(list/help/stats/impact/doctor/pack)不是对 skill-interface 的依赖。
*/
import fs from 'node:fs';
import path from 'node:path';
const PI_SKILL_REF = /pi-skill\s+([a-z][a-z0-9-]*)/g;
const QUOTED = /["'`]([a-z][a-z0-9-]*)["'`]/g;
/** 同一行里既 spawn 了 pi-skill、又把目标名当引号字符串传(argv 数组/参数列表)。 */
const SPAWN_LINE = /(?:["'`]pi-skill["'`]|\bpi_skill\s*\(|pi-skill["'`]?\s*,)/;
const RELATIVE_IMPORT = /(?:from\s+|require\(\s*|import\s+\(?\s*)['"](\.[^'"]+)['"]/g;
const UNIVERSAL = new Set(['list', 'help', 'stats', 'impact', 'doctor', 'pack', 'skill', 'pi-skill']);
const SKIP_DIRS = new Set(['tests', 'test', 'node_modules', '__pycache__', 'vendor', '.git']);
const SCRIPT_EXT = /\.(mjs|js|cjs|ts|py|sh|zsh|bash|fish|rb|pl)$/;
const TEST_FILE = /(^test_|_test\.|\.test\.|\.spec\.)/;
/** 名字(技能名 + 别名 + admin 别名)→ 技能名。 */
export function nameIndex(skills) {
const index = new Map();
for (const skill of skills) {
index.set(skill.name, skill.name);
for (const alias of skill.declared?.aliases ?? []) index.set(alias, skill.name);
for (const item of skill.declared?.admin ?? []) if (item.alias) index.set(item.alias, skill.name);
}
return index;
}
function collectScripts(dir) {
const out = [];
let entries = [];
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return out; }
for (const entry of entries) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
if (!SKIP_DIRS.has(entry.name)) out.push(...collectScripts(full));
continue;
}
if (!SCRIPT_EXT.test(entry.name) || TEST_FILE.test(entry.name)) continue;
out.push(full);
}
return out;
}
function readText(file) {
try {
const buffer = fs.readFileSync(file);
if (buffer.includes(0)) return '';
return buffer.toString('utf8');
} catch { return ''; }
}
const NON_ASCII = /[\u0080-\uFFFF]/;
const COMMAND_SUBST = /(?:\$\(|`)\s*$/;
const merge = (into, from) => { for (const [key, value] of from) into.set(key, (into.get(key) ?? 0) + value); };
/** 提示文案判定:同一行匹配点前面有中文等非 ASCII,且不在命令替换里 → 算提示(soft)。 */
function isHintContext(text, index) {
const lineStart = text.lastIndexOf('\n', index - 1) + 1;
const prefix = text.slice(lineStart, index);
return NON_ASCII.test(prefix) && !COMMAND_SUBST.test(prefix);
}
/** 一段文本里对其它技能的引用计数(已做别名归一与自引用过滤);每个引用只在 hard 或 soft 之一。 */
function refsIn(text, index, self) {
const hard = new Map();
const soft = new Map();
const add = (into, target) => into.set(target, (into.get(target) ?? 0) + 1);
for (const match of text.matchAll(PI_SKILL_REF)) {
const raw = match[1];
if (UNIVERSAL.has(raw)) continue;
const target = index.get(raw);
if (!target || target === self) continue;
add(isHintContext(text, match.index) ? soft : hard, target);
}
for (const match of text.matchAll(RELATIVE_IMPORT)) {
const segments = match[1].split('/');
if (segments[0] !== '..') continue;
const head = segments.find(segment => segment !== '..' && segment !== '.');
const target = head ? index.get(head) : undefined;
if (target && target !== self) add(hard, target);
}
// 派生式调用:**同一行**里既 spawn 了 pi-skill,又有带引号的目标名(argv 数组/参数列表,如
// `execFileSync('pi-skill', ['pi-settings-edit', …])`)。严格同行以避开「文件里恰好提了别的技能名」的误报。
// skill-interface 自己的脚本里到处是调用示例(本工具就是讲这些的),不参与此规则。
if (self !== 'skill-interface') {
for (const line of text.split('\n')) {
if (!SPAWN_LINE.test(line)) continue;
for (const match of line.matchAll(QUOTED)) {
if (UNIVERSAL.has(match[1])) continue;
const target = index.get(match[1]);
if (target && target !== self) add(hard, target);
}
}
}
return { hard, soft };
}
/** 一个技能对外的引用:{ hard, soft }(Map<技能名, 命中次数>)。 */
export function scanUsages(skill, index = new Map()) {
const hard = new Map();
const soft = new Map();
for (const file of collectScripts(path.join(skill.dir, 'scripts'))) {
const found = refsIn(readText(file), index, skill.name);
merge(hard, found.hard);
merge(soft, found.soft);
}
for (const file of [skill.skillFile, path.join(skill.dir, 'REFERENCE.md')]) {
if (!file || !fs.existsSync(file)) continue;
const found = refsIn(readText(file), index, skill.name);
merge(soft, found.hard);
merge(soft, found.soft);
}
for (const target of hard.keys()) soft.delete(target);
return { hard, soft };
}
/** 整个技能库的依赖图:nodes[name] = { skill, hard, soft, declared },另有 cycles。 */
export function dependencyGraph(skills, index = nameIndex(skills)) {
const nodes = new Map();
for (const skill of skills) {
const usage = scanUsages(skill, index);
const declared = new Map();
for (const dep of skill.declared?.dependsOn ?? []) declared.set(index.get(dep.skill) ?? dep.skill, dep);
nodes.set(skill.name, { skill, hard: usage.hard, soft: usage.soft, declared });
}
for (const [name, node] of nodes) {
node.hard.delete(name);
node.soft.delete(name);
node.declared.delete(name);
}
return { index, nodes, cycles: findCycles(nodes) };
}
/** 环检测:hard ∪ declared 边(未声明的真实调用也算边)。返回环数组(每个环节点序列)。 */
export function findCycles(nodes) {
const cycles = [];
const seen = new Set();
const state = new Map();
const stack = [];
const edgesOf = name => {
const node = nodes.get(name);
const targets = new Set([...(node?.hard.keys() ?? []), ...(node?.declared.keys() ?? [])]);
return [...targets].filter(target => nodes.has(target));
};
const visit = name => {
state.set(name, 1);
stack.push(name);
for (const next of edgesOf(name)) {
if (state.get(next) === 1) {
const cycle = stack.slice(stack.indexOf(next));
const key = [...cycle].sort().join('>');
if (!seen.has(key)) { seen.add(key); cycles.push(cycle); }
} else if ((state.get(next) ?? 0) === 0) visit(next);
}
stack.pop();
state.set(name, 2);
};
for (const name of nodes.keys()) if ((state.get(name) ?? 0) === 0) visit(name);
return cycles;
}
/** 影响面:谁引用了 target(hard = 脚本真实调用,soft = 仅文档提及)。 */
export function consumersOf(graph, target) {
const hard = [];
const soft = [];
for (const [name, node] of graph.nodes) {
if (name === target) continue;
if (node.hard.has(target)) hard.push(name);
else if (node.soft.has(target)) soft.push(name);
}
return { hard: hard.sort(), soft: soft.sort() };
}

View File

@ -0,0 +1,143 @@
#!/usr/bin/env node
/**
* 技能 PATH 入口的注册与核对(原语:把 interface.json 的 aliases 变成 PATH 短命令)。
*
* 用法:
* skill-entry-install plan [--json] [--dry-run] 打印将写入的内容(默认就是 dry-run)
* skill-entry-install run [--json] 写入 / 更新(幂等;非本工具生成的文件不覆盖)
* skill-entry-install status [--json] 核对:缺失 / 漂移 / 孤儿(不在清单里的入口)
*
* 输出:结论行 + key=value 字段;错误给 error= 与 hint=。
* 退出码:0 成功;2 调用错误;3 有需要处理的问题(status 发现漂移/缺失时)。
*/
import fs from 'node:fs';
import path from 'node:path';
import { loadSkills, repoRoot, shimDir, shimOnPath, shimText, shimName, aliasOf, SHIM_MARKER, ERROR_CODES } from './skill-registry.mjs';
const USAGE = `skill-entry-install — 注册技能 PATH 入口
skill-entry-install plan [--json] [--dry-run] 预览将写入的内容(默认不落盘)
skill-entry-install run [--json] 写入 / 更新(幂等)
skill-entry-install status [--json] 核对缺失 / 漂移 / 孤儿
skill-entry-install help
入口来源:各技能 interface.json 的 aliases;内容由 entry + runner 生成,不手改。`;
const SAY = (message, fields = {}) => {
console.log(message);
for (const [key, value] of Object.entries(fields)) console.log(` ${key}=${value}`);
};
const fail = (code, message, hint) => {
console.error(`error=${code} ${message}${hint ? `\nhint=${hint}` : ''}`);
process.exit(2);
};
const [command = 'help', ...rest] = process.argv.slice(2);
if (['help', '-h', '--help'].includes(command)) { console.log(USAGE); process.exit(0); }
if (!['plan', 'run', 'status'].includes(command)) fail('usage', `未知命令:${command}`, 'skill-entry-install help');
const json = rest.includes('--json');
const dryRun = command !== 'run' || rest.includes('--dry-run');
const dir = shimDir();
const repo = repoRoot();
const skills = loadSkills(repo);
const broken = skills.filter(skill => skill.errors.length);
/** 期望写入的入口清单:{alias, skill, content, file} */
const expected = [];
for (const skill of skills) {
if (skill.errors.length) continue;
const aliases = [
...(skill.declared.aliases ?? []).map(alias => ({ alias, argv: skill.entry.argv })),
...skill.admin.map(item => ({ alias: item.alias, argv: item.argv })),
];
for (const { alias, argv } of aliases) {
if (expected.some(item => item.alias === alias)) {
fail('conflict', `别名 ${alias} 被多个技能/运维脚本声明`, '改 interface.json 消除重名');
}
expected.push({ alias, skill: skill.name, content: shimText({ argv }), file: path.join(dir, shimName(alias)) });
}
}
const entries = fs.existsSync(dir) ? fs.readdirSync(dir) : [];
const owned = entries.filter(name => {
const file = path.join(dir, name);
try { return fs.lstatSync(file).isSymbolicLink() ? fs.realpathSync(file).startsWith(fs.realpathSync(repo)) : fs.readFileSync(file, 'utf8').includes(SHIM_MARKER); } catch { return false; }
});
const ours = new Set(expected.map(item => item.alias));
const orphans = owned.filter(name => !ours.has(aliasOf(name)));
const missing = expected.filter(item => !fs.existsSync(item.file));
const drifted = expected.filter(item => fs.existsSync(item.file) && fs.readFileSync(item.file, 'utf8') !== item.content);
const conflicts = expected.filter(item => fs.existsSync(item.file) && !fs.readFileSync(item.file, 'utf8').includes(SHIM_MARKER));
if (command === 'status') {
const problems = missing.length + drifted.length + conflicts.length + orphans.length + broken.length; if (json) {
console.log(JSON.stringify({
ok: problems === 0,
binDir: dir,
onPath: shimOnPath(dir),
registered: expected.length - missing.length,
missing: missing.map(item => item.alias),
drifted: drifted.map(item => item.alias),
conflicts: conflicts.map(item => item.alias),
orphans,
invalidInterfaces: broken.map(skill => ({ skill: skill.name, check: skill.errors[0].check, message: skill.errors[0].message })),
}, null, 2));
} else {
SAY(problems ? `${problems} 处需要处理` : '入口全部就绪', {
bin_dir: dir,
on_path: shimOnPath(dir),
registered: `${expected.length - missing.length}/${expected.length}`,
missing: missing.map(item => item.alias).join(',') || '—',
drifted: drifted.map(item => item.alias).join(',') || '—',
conflicts: conflicts.map(item => item.alias).join(',') || '—',
orphans: orphans.join(',') || '—',
invalid_interfaces: broken.map(skill => skill.name).join(',') || '—',
});
if (problems) console.log(`hint=${missing.length || drifted.length ? 'skill-entry-install run' : 'skillcheck list 与 interface.json 对齐'}`);
}
process.exit(problems ? 3 : 0);
}
if (conflicts.length) {
for (const item of conflicts) {
console.error(`error=conflict ${item.file} 已存在且不是本工具生成的入口,拒绝覆盖`);
console.error(`hint=备份该文件后删除,再跑 skill-entry-install run`);
}
process.exit(2);
}
const written = [];
if (dryRun) {
for (const item of missing.concat(drifted)) written.push(item.alias);
} else {
fs.mkdirSync(dir, { recursive: true });
for (const item of expected) {
const before = fs.existsSync(item.file) ? fs.readFileSync(item.file, 'utf8') : '';
if (before === item.content) continue;
const temporary = `${item.file}.${process.pid}.tmp`;
fs.writeFileSync(temporary, item.content, { mode: 0o755 });
fs.renameSync(temporary, item.file);
written.push(item.alias);
}
}
const skipped = expected.filter(item => !written.includes(item.alias)).map(item => item.alias);
if (json) {
console.log(JSON.stringify({ ok: broken.length === 0, dryRun, binDir: dir, onPath: shimOnPath(dir), written, unchanged: skipped, orphans, skippedSkills: broken.map(skill => skill.name) }, null, 2));
} else {
SAY(dryRun ? `将写入 ${written.length} 个入口(未落盘)` : `入口已就绪:写入/更新 ${written.length} 个`, {
bin_dir: dir,
on_path: shimOnPath(dir),
written: written.join(',') || '—',
unchanged: skipped.join(',') || '—',
orphans: orphans.join(',') || '—',
skipped_skills: broken.map(skill => skill.name).join(',') || '—',
});
if (!shimOnPath(dir)) console.log(`hint=把 ${dir} 加入 PATH 后新终端才能直接调用别名`);
if (dryRun && written.length) console.log('hint=skill-entry-install run');
if (orphans.length) console.log(`hint=这些入口不在任何 interface.json 的 aliases 里,删除或补声明:${orphans.join(',')}`);
for (const skill of broken) console.log(`跳过 ${skill.name}:[${skill.errors[0].check}] ${skill.errors[0].message}`);
if (broken.length) console.log(`hint=skillcheck ${broken.map(skill => skill.name).join(' ')}`);
}
process.exit(broken.length ? 3 : 0);

View File

@ -0,0 +1,206 @@
/**
* skill-invocation-log — pi-skill 调用留痕的原语(库,不是 CLI)。
*
* 目的:让「哪些原语没人用 / 哪些接口老出错 / 哪些技能已腐烂」有数据可答。
* 边界:只记形状不记内容——参数个数与旗标名,不记参数值、不记输出正文。
*
* 日志:<agentDir>/local/skills/skill-interface/invocations.jsonl(每行一条 JSON)
* - 环境变量 PI_SKILL_LOG=0 关闭;PI_SKILL_LOG_DIR 覆盖目录
* - 超过 ROTATE_BYTES 轮转到 invocations.1.jsonl(只留一份旧的)
* - 任何失败都吞掉:留痕永不影响主流程
*/
import fs from 'node:fs';
import path from 'node:path';
import { agentDir, repoRoot } from './skill-registry.mjs';
export const LOG_FILE = 'invocations.jsonl';
export const ROTATE_BYTES = 20 * 1024 * 1024;
const MAX_FLAGS = 12;
const ERROR_RE = /(?:^|[\s,;(])error=([a-z_]+)/m;
export const logDir = () => path.resolve(process.env.PI_SKILL_LOG_DIR || path.join(agentDir(repoRoot()), 'local', 'skills', 'skill-interface'));
export const logPath = () => path.join(logDir(), LOG_FILE);
export const logEnabled = () => process.env.PI_SKILL_LOG !== '0';
/** 从输出尾部提取 error=<code>;仅在退出码非 0 时有意义。 */
export function extractErrorCode(text) {
const match = ERROR_RE.exec(text || '');
return match ? match[1] : null;
}
/** 参数形状:位置参数个数 + 旗标名(去值、去重、限量)。不返回任何参数值。 */
export function argShape(args = []) {
const flags = [];
let argc = 0;
for (const value of args) {
if (typeof value === 'string' && value.startsWith('--') && value.length > 2) {
const name = value.slice(2).split('=')[0];
if (name && !flags.includes(name) && flags.length < MAX_FLAGS) flags.push(name);
} else {
argc += 1;
}
}
return { argc, flags };
}
/**
* 构造一条记录。字段名是稳定契约(stats 与外部消费者依赖它们):
* ts skill command argc flags exit error ms session model resolved
*/
export function buildRecord({ skill, command, args = [], exit, output = '', ms = 0, resolved = true, startedAt = Date.now() }) {
const shape = argShape(args);
const code = Number.isInteger(exit) ? exit : 1;
return {
ts: new Date(startedAt).toISOString(),
skill: String(skill ?? ''),
command: command == null ? null : String(command),
argc: shape.argc,
flags: shape.flags,
exit: code,
error: code === 0 ? null : (extractErrorCode(output) ?? (code === 2 ? 'usage' : null)),
ms: Math.max(0, Math.round(ms)),
session: process.env.PI_SESSION_ID || null,
model: process.env.PI_MODEL || null,
resolved: resolved !== false,
};
}
/** 追加一条记录;失败静默。返回是否写入。 */
export function appendInvocation(record) {
if (!logEnabled()) return false;
try {
const dir = logDir();
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
const file = path.join(dir, LOG_FILE);
try {
if (fs.statSync(file).size > ROTATE_BYTES) fs.renameSync(file, path.join(dir, 'invocations.1.jsonl'));
} catch { /* 文件不存在或不可读,直接追加 */ }
fs.appendFileSync(file, `${JSON.stringify(record)}\n`, { mode: 0o600 });
return true;
} catch {
return false;
}
}
/** 解析 --since:7d / 24h / 2w / all;返回起始毫秒时间戳(all → 0)。非法返回 null。 */
export function parseSince(value, now = Date.now()) {
if (value == null || value === '') return now - 30 * 86_400_000;
if (value === 'all') return 0;
const match = /^(\d+)([hdw])$/.exec(String(value));
if (!match) return null;
const unit = { h: 3_600_000, d: 86_400_000, w: 7 * 86_400_000 }[match[2]];
return now - Number(match[1]) * unit;
}
/** 读取记录(含轮转的旧文件),按 since 过滤;坏行跳过。 */
export function readInvocations({ since = 0 } = {}) {
const dir = logDir();
const records = [];
for (const name of ['invocations.1.jsonl', LOG_FILE]) {
let text;
try { text = fs.readFileSync(path.join(dir, name), 'utf8'); } catch { continue; }
for (const line of text.split('\n')) {
if (!line.trim()) continue;
try {
const record = JSON.parse(line);
if (record && typeof record.ts === 'string' && Date.parse(record.ts) >= since) records.push(record);
} catch { /* 坏行跳过 */ }
}
}
return records;
}
const counter = (map, key) => map.set(key, (map.get(key) ?? 0) + 1);
const sortedCounts = map => [...map.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
const toObject = map => Object.fromEntries(sortedCounts(map));
function bucket() {
return { calls: 0, failures: 0, usage_errors: 0, errors: new Map(), last: null, ms: 0 };
}
function tally(target, record) {
target.calls += 1;
target.ms += Number(record.ms) || 0;
if (record.exit !== 0) {
target.failures += 1;
if (record.exit === 2) target.usage_errors += 1;
if (record.error) counter(target.errors, record.error);
}
if (!target.last || record.ts > target.last) target.last = record.ts;
}
const finish = target => ({
calls: target.calls,
failures: target.failures,
usage_errors: target.usage_errors,
errors: toObject(target.errors),
last: target.last,
avg_ms: target.calls ? Math.round(target.ms / target.calls) : 0,
});
/**
* 汇总。skills 参数是 loadSkills() 的结果,用来算「从未被调用」。
* 返回结构是稳定 JSON 契约(见 pi-skill stats --json)。
*/
export function summarize(records, { skills = [], skill = null, minCalls = 3 } = {}) {
const total = bucket();
const perSkill = new Map();
const perCommand = new Map(); // key: skill\u0000command
const unknown = new Map();
for (const record of records) {
if (skill && record.skill !== skill) continue;
tally(total, record);
if (record.resolved === false) { counter(unknown, record.skill); continue; }
if (!perSkill.has(record.skill)) perSkill.set(record.skill, bucket());
tally(perSkill.get(record.skill), record);
const key = `${record.skill}\u0000${record.command ?? 'help'}`;
if (!perCommand.has(key)) perCommand.set(key, bucket());
tally(perCommand.get(key), record);
}
const commandsOf = name => [...perCommand.entries()]
.filter(([key]) => key.startsWith(`${name}\u0000`))
.map(([key, value]) => ({ name: key.split('\u0000')[1], ...finish(value) }))
.sort((a, b) => b.calls - a.calls || a.name.localeCompare(b.name));
const declaredOf = name => (skills.find(item => item.name === name)?.declared?.commands ?? []).map(command => command.name);
const skillRows = [...perSkill.entries()]
.map(([name, value]) => {
const commands = commandsOf(name);
const seen = new Set(commands.map(command => command.name));
return { name, ...finish(value), commands, never_called_commands: declaredOf(name).filter(command => !seen.has(command)) };
})
.sort((a, b) => b.calls - a.calls || a.name.localeCompare(b.name));
const neverCalledSkills = skills
.filter(item => !item.docOnly && !perSkill.has(item.name) && (!skill || item.name === skill))
.map(item => item.name);
const topFailing = [...perCommand.entries()]
.map(([key, value]) => { const [name, command] = key.split('\u0000'); return { skill: name, command, ...finish(value) }; })
.filter(row => row.calls >= minCalls && row.failures > 0)
.sort((a, b) => (b.failures / b.calls) - (a.failures / a.calls) || b.failures - a.failures)
.slice(0, 5)
.map(row => ({ ...row, failure_rate: Math.round((row.failures / row.calls) * 1000) / 10 }));
return {
total: total.calls,
failures: total.failures,
usage_errors: total.usage_errors,
errors: toObject(total.errors),
skills: skillRows,
never_called_skills: neverCalledSkills,
unknown_skills: toObject(unknown),
top_failing_commands: topFailing,
};
}
/** 相对时间:刚刚 / 5m前 / 3h前 / 2d前。 */
export function ago(iso, now = Date.now()) {
if (!iso) return '—';
const delta = Math.max(0, now - Date.parse(iso));
if (delta < 60_000) return '刚刚';
if (delta < 3_600_000) return `${Math.floor(delta / 60_000)}m前`;
if (delta < 86_400_000) return `${Math.floor(delta / 3_600_000)}h前`;
return `${Math.floor(delta / 86_400_000)}d前`;
}

View File

@ -0,0 +1,244 @@
#!/usr/bin/env node
/**
* 可分发契约:把单个技能打成「别人拿到就能用」的包(库 + CLI)。
*
* 白名单打包(只装技能本体:SKILL.md / REFERENCE.md / interface.json / contract.lock.json /
* CHANGELOG.md / scripts 等),绝不带本机数据(local 配置、state、日志、缓存、.env)。
* 打包前做发布门禁:skillcheck 无 error、契约基线不漂移、泄漏扫描无 error 级发现。
* 产出:<out>/<技能>-<版本>/ + MANIFEST.json(版本/平台/命令/依赖/配置 schema/文件哈希)+ tar.gz。
*
* 用法:
* skill-pack <技能> [--out <目录>] [--json] [--force] [--dry-run]
*
* 退出码:0 已打包;3 有阻断(泄漏/门禁不过,加 --force 也只跳过泄漏之外的阻断);2 调用错误。
*/
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import crypto from 'node:crypto';
import { spawnSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { contractStatus } from './skill-contract.mjs';
export const MANIFEST_VERSION = 1;
/** 只装这些(技能是「流程 + 脚本」,不是数据)。 */
const WHITELIST_FILES = ['SKILL.md', 'REFERENCE.md', 'interface.json', 'contract.lock.json', 'CHANGELOG.md', 'LICENSE', 'LICENSE.md', 'VERSION'];
const WHITELIST_DIRS = ['scripts', 'assets', 'templates', 'references'];
/** 目录内也绝不装这些(本机数据/缓存/密钥)。 */
const DENY = [/^\./, /^node_modules$/, /^__pycache__$/, /\.pyc$/i, /\.log$/i, /\.jsonl$/i, /^config\.json$/i, /^state\.json$/i, /\.local\.json$/i, /^\.env/i, /\.bak$/i, /\.tmp$/i, /^\.DS_Store$/, /\.sqlite$/i];
/** 键名列表之类的“假家目录”(如 up/down/home/end)不算泄漏。 */
const HOME_STOPWORDS = new Set(['end', 'home', 'space', 'down', 'left', 'right', 'up', 'del', 'enter', 'esc', 'tab', 'user', 'usr', 'you', 'me', 'name', 'yourname', 'username', 'xxx']);
const benignHome = capture => /[<>{}*$]/.test(capture) || HOME_STOPWORDS.has(String(capture).toLowerCase());
/** 泄漏扫描:error 阻断打包,warn 只提示。 */
const LEAK_PATTERNS = [
{ level: 'error', name: 'secret', re: /\b(sk-[A-Za-z0-9]{16,}|ghp_[A-Za-z0-9_]{16,}|github_pat_[A-Za-z0-9_]{16,}|AKIA[0-9A-Z]{12,})\b/ },
{ level: 'error', name: 'private-key', re: /-----BEGIN [A-Z ]*PRIVATE KEY-----/ },
{ level: 'error', name: 'password-literal', re: /\b(password|passwd|secret|token|api[_-]?key)\s*[:=]\s*["'][^"'{}$][^"']{7,}["']/i },
{ level: 'error', name: 'home-path', re: /\/(?:Users|home)\/([A-Za-z0-9._-]{2,})\//, allow: benignHome },
{ level: 'error', name: 'windows-home-path', re: /[A-Za-z]:\\Users\\([^\\\s"']+)\\/, allow: benignHome },
{ level: 'error', name: 'dmp-ticket', re: /\b[A-Z]{2,}[A-Z0-9]*_[A-Z0-9]+_\d{8}_\d{4}\b/, allow: capture => /^(DEMO|EXAMPLE|SAMPLE|TEST|FAKE|XXX)/.test(String(capture)) },
{ level: 'warn', name: 'email', re: /\b[\w.+-]+@[\w-]+\.[A-Za-z]{2,}\b/ },
{ level: 'warn', name: 'internal-domain', re: /\.(internal|corp|intra)\b/i },
];
const SKIP_DIRS = new Set(['node_modules', '__pycache__', '.git']);
function listFiles(dir, base = dir) {
const out = [];
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
if (entry.name === '.DS_Store') continue;
const full = path.join(dir, entry.name);
const rel = path.relative(base, full);
if (entry.isDirectory()) {
if (SKIP_DIRS.has(entry.name) || DENY.some(pattern => pattern.test(entry.name))) continue;
out.push(...listFiles(full, base));
continue;
}
if (DENY.some(pattern => pattern.test(entry.name))) continue;
out.push(rel);
}
return out.sort();
}
/** 包内文件清单:白名单顶层文件 + 白名单目录(排除数据/缓存)。 */
export function packageFiles(skillDir) {
const files = WHITELIST_FILES.filter(name => fs.existsSync(path.join(skillDir, name)));
for (const dir of WHITELIST_DIRS) {
const full = path.join(skillDir, dir);
if (!fs.existsSync(full)) continue;
for (const rel of listFiles(full, skillDir)) files.push(rel);
}
return [...new Set(files)].sort();
}
function scanText(text, rel) {
const findings = [];
const lines = text.split('\n');
for (const [index, line] of lines.entries()) {
for (const pattern of LEAK_PATTERNS) {
for (const match of line.matchAll(new RegExp(pattern.re.source, pattern.re.flags.includes('g') ? pattern.re.flags : `${pattern.re.flags}g`))) {
// allow(capture):有些命中是占位符/示例(C:\Users\<你>、DEMO_FUNC_…、键名表里的 home/end),不算泄漏
const capture = match[1] ?? match[0];
if (pattern.allow && pattern.allow(capture, match)) continue;
findings.push({ level: pattern.level, kind: pattern.name, file: rel, line: index + 1, excerpt: match[0].slice(0, 60) });
}
}
}
return findings;
}
export function scanLeaks(skillDir, files) {
const findings = [];
for (const rel of files) {
if (!/\.(md|mjs|js|cjs|ts|py|sh|zsh|ps1|cmd|bat|json|txt|ya?ml)$/i.test(rel)) continue;
let text = '';
try { text = fs.readFileSync(path.join(skillDir, rel), 'utf8'); } catch { continue; }
findings.push(...scanText(text, rel));
}
return findings;
}
/** 跨技能相对 import(打包后必须一起带上,或换成 pi-skill 调用)。 */
export function externalImports(skillDir, files) {
const found = [];
for (const rel of files) {
if (!/\.(mjs|js|cjs|ts)$/i.test(rel)) continue;
let text = '';
try { text = fs.readFileSync(path.join(skillDir, rel), 'utf8'); } catch { continue; }
for (const match of text.matchAll(/(?:from\s+|require\(\s*|import\s+\(?\s*)['"](\.[^'"]+)['"]/g)) {
const target = path.resolve(path.dirname(path.join(skillDir, rel)), match[1]);
if (!target.startsWith(skillDir + path.sep)) found.push({ from: rel, import: match[1], target: path.relative(skillDir, target) });
}
}
return found;
}
const sha256 = file => crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex').slice(0, 16);
function git(repo, args) {
const result = spawnSync('git', ['-C', repo, ...args], { encoding: 'utf8' });
return result.status === 0 ? result.stdout.trim() : '';
}
export function packSkill(skill, { outDir, force = false, dryRun = false } = {}) {
const skillDir = skill.dir;
const files = packageFiles(skillDir);
const leaks = scanLeaks(skillDir, files);
const imports = externalImports(skillDir, files);
const repo = skill.repo ?? path.resolve(skillDir, '../..');
const describe = git(repo, ['describe', '--tags', '--always']) || 'dev';
const commit = git(repo, ['rev-parse', '--short', 'HEAD']) || 'nogit';
const dirty = Boolean(git(repo, ['status', '--porcelain', '--', skillDir]));
const short = describe.replace(/^v/, '');
const tagged = !/^[0-9a-f]{7,}$/.test(describe);
const version = `${short}${tagged ? `+${commit}` : ''}${dirty ? '.dirty' : ''}`;
const contract = contractStatus(skill);
const blockers = [];
const errors = leaks.filter(leak => leak.level === 'error');
if (errors.length && !force) blockers.push(`泄漏扫描发现 ${errors.length} 处 error 级内容(密钥/家目录/真实工单号)`);
if (contract.state === 'drift') blockers.push('契约基线漂移:先 skill-contract diff 处理并更新基线');
if (skill.errors?.length) blockers.push(`interface.json 有 ${skill.errors.length} 个结构错误:skillcheck ${skill.name}`);
const manifest = {
manifest_version: MANIFEST_VERSION,
name: skill.name,
summary: skill.declared?.summary ?? '',
tier: skill.declared?.tier ?? 0,
status: skill.declared?.status ?? 'stable',
version,
source: { path: `skills/${skill.name}`, commit, describe, dirty, packed_at: new Date().toISOString() },
platforms: skill.platforms ?? [],
entry: skill.declared?.entry ?? null,
commands: (skill.declared?.commands ?? []).map(command => command.name),
errors: skill.declared?.errors ?? [],
aliases: skill.declared?.aliases ?? [],
admin: (skill.declared?.admin ?? []).map(item => item.path),
depends_on: skill.declared?.dependsOn ?? [],
requires: skill.declared?.requires ?? [],
config: skill.declared?.config ?? [],
external_imports: imports,
contract: contract.current,
files: files.map(rel => ({ path: rel, bytes: fs.statSync(path.join(skillDir, rel)).size, sha256: sha256(path.join(skillDir, rel)) })),
leak_scan: { errors: errors.length, warnings: leaks.length - errors.length, findings: leaks },
};
if (blockers.length || dryRun) return { ok: !blockers.length, blocked: blockers, manifest, leaks, imports, files, version };
const destination = path.join(outDir, `${skill.name}-${version}`);
fs.rmSync(destination, { recursive: true, force: true });
fs.mkdirSync(destination, { recursive: true });
for (const rel of files) {
const target = path.join(destination, rel);
fs.mkdirSync(path.dirname(target), { recursive: true });
fs.copyFileSync(path.join(skillDir, rel), target);
}
fs.writeFileSync(path.join(destination, 'MANIFEST.json'), `${JSON.stringify(manifest, null, 2)}\n`);
let tarball = '';
// 包内顶层目录用技能名(不是 <技能>-<版本>):对方解包后直接是 skills/<技能>/,不用改名。
// 不用 tar 的改名选项(macOS 是 bsdtar、GNU tar 与 bsdtar 语法不同):先搭一个临时 staging 目录再打包。
const staging = fs.mkdtempSync(path.join(os.tmpdir(), 'skill-pack-'));
try {
fs.cpSync(destination, path.join(staging, skill.name), { recursive: true });
const tar = spawnSync('tar', ['-czf', `${destination}.tar.gz`, '-C', staging, skill.name], { encoding: 'utf8' });
if (tar.status === 0) tarball = `${destination}.tar.gz`;
} finally {
fs.rmSync(staging, { recursive: true, force: true });
}
return { ok: true, blocked: [], dir: destination, tarball, manifest, leaks, imports, files, version, dependsOn: skill.declared?.dependsOn ?? [] };
}
// ---- CLI ----
const isMain = (function () {{ try {{ return Boolean(process.argv[1]) && fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url)); }} catch {{ return false; }} }})(); // realpath:macOS 的 /tmp、/var 是符号链接,直接比字符串会让入口静默不执行(退出码 0)
/** 把打包结果格式化成给人看的几行(CLI 与 pi-skill pack 共用,避免两份输出漂移)。 */
export function packReport(result, skill) {
const errors = (result.leaks ?? []).filter(leak => leak.level === 'error');
const warnings = (result.leaks ?? []).filter(leak => leak.level === 'warn');
const bytes = result.files.reduce((sum, rel) => sum + fs.statSync(path.join(skill.dir, rel)).size, 0);
const head = result.ok && result.dir
? `pack ${skill.name} — 已打包 ${result.files.length} 个文件(${(bytes / 1024).toFixed(1)} KB),版本 ${result.version}`
: `pack ${skill.name} — ${result.dir ? '已打包' : result.blocked?.length ? '未打包' : '预览(未写出)'}:${result.files.length} 个文件,版本 ${result.version}`;
const lines = [head];
for (const leak of [...errors, ...warnings]) lines.push(`${leak.level === 'error' ? 'LEAK ' : 'warn '} ${leak.kind} ${leak.file}:${leak.line} ${leak.excerpt}`);
for (const blocker of result.blocked ?? []) lines.push(`blocked: ${blocker}`);
if (result.imports?.length) lines.push(`跨技能 import:${result.imports.map(item => item.target).join('、')}(对方也需要这些技能或一起打包)`);
const deps = (result.dependsOn ?? []).map(item => (typeof item === 'string' ? item : item.skill)).filter(Boolean);
if (deps.length) lines.push(`依赖技能:${deps.join('、')}(要一起给:pi-skill pack ${deps.join(' . pi-skill pack '.replace(' . ', ';'))})`);
if (result.ok && result.dir) {
lines.push(`dir=${result.dir}`);
if (result.tarball) lines.push(`tarball=${result.tarball}`);
lines.push(deps.length
? `hint=对方把${deps.length ? '这两个(技能 + 依赖)' : ''}目录都放进 <shared>/skills/ 后跑 pi-skill doctor;缺配置它会自己说要什么`
: 'hint=对方把目录放进 <shared>/skills/ 后跑 pi-skill list 与 pi-skill doctor;缺什么它会自己说');
} else if (result.blocked?.length) lines.push('hint=按上面逐条处理(泄漏项请先删掉再打;确认是误报才用 --force)');
lines.push(`字段: skill=${skill.name} files=${result.files.length} version=${result.version} leaks=${errors.length} warnings=${warnings.length} blocked=${(result.blocked ?? []).length}${result.dir ? ` dir=${result.dir}` : ''}`);
return lines;
}
export function packUsage() {
return `skill-pack — 把单个技能打成可分发产物(白名单 + 泄漏扫描 + 发布门禁 + MANIFEST)\n\nskill-pack <技能> [--out <目录>] [--json] [--force] [--dry-run]\n\n默认输出到 ~/.pi/agent/local/pack/<技能>-<版本>/(附 .tar.gz);包内只含技能本体,不含本机配置/日志/缓存。\n泄漏扫描:密钥字面量、绝对家目录、真实工单号(error 阻断);邮箱、内网域名(warn)。\n退出码:0 已打包;3 被阻断;2 调用错误。`;
}
if (isMain) {
process.env.PI_SKILL_LOG = '0';
const { loadSkills, repoRoot } = await import('./skill-registry.mjs');
const { agentDirOf } = await import('./skill-config.mjs');
const args = process.argv.slice(2);
if (!args.length || args.includes('help') || args.includes('-h')) { console.log(packUsage()); process.exit(args.length ? 0 : 2); }
const unknown = args.filter(value => value.startsWith('-') && !['--json', '--force', '--dry-run', '--out'].includes(value));
if (unknown.length) { console.error(`error=usage 未知参数 ${unknown.join(' ')}\nhint=skill-pack help`); process.exit(2); }
const target = args.find(value => !value.startsWith('-'));
const outIndex = args.indexOf('--out');
const outDir = outIndex >= 0 ? path.resolve(args[outIndex + 1] ?? '') : path.join(agentDirOf(), 'local', 'pack');
const skill = loadSkills(repoRoot()).find(candidate => candidate.name === target || (candidate.declared?.aliases ?? []).includes(target));
if (!skill) { console.error(`error=notfound 没有技能「${target}」\nhint=pi-skill list`); process.exit(2); }
const result = packSkill(skill, { outDir, force: args.includes('--force'), dryRun: args.includes('--dry-run') });
if (args.includes('--json')) console.log(JSON.stringify(result, null, 2));
else console.log(packReport(result, skill).join('\n'));
process.exit(result.ok ? 0 : 3);
}

View File

@ -0,0 +1,285 @@
#!/usr/bin/env node
/**
* 技能接口原语:发现技能、读并校验 interface.json、把接口解析成可执行 argv、生成 PATH 入口内容。
*
* 只做这四件事,不含任何具体技能的语义,也不直接打印用户可见文案。
* 上层:pi-skill.mjs(转发)、skill-entry-install.mjs(入口注册)、skillcheck.mjs(契约检查)。
*/
import fs from 'node:fs';
import path from 'node:path';
import { spawnSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { platformsFromMarkdown } from '../../../runtime/platform-resources.mjs';
import { CONFIG_TYPES } from './skill-config.mjs';
export const TIERS = [0, 1, 2, 3];
export const LIFECYCLE = ['status', 'start', 'stop', 'logs'];
/** 失败契约的封闭错误码集合;工具只能用这些。 */
export const ERROR_CODES = ['usage', 'deps', 'platform', 'auth', 'config', 'notfound', 'conflict', 'blocked', 'timeout', 'external', 'internal'];
export const RUNNERS = { '.mjs': 'node', '.js': 'node', '.cjs': 'node', '.ts': 'node', '.py': 'python3', '.sh': 'bash', '.zsh': 'zsh' };
export const SKILL_NAME = /^[a-z][a-z0-9-]*$/;
/** 技能生命周期状态;省略 = stable。deprecated 只允许经退役流程设置(见 ROADMAP P6.2)。 */
export const SKILL_STATUSES = ['stable', 'experimental', 'deprecated'];
/** 外部环境依赖的种类:程序 / 运行时 / 外部系统 / 账号。 */
export const REQUIRE_KINDS = ['binary', 'runtime', 'service', 'account'];
/** 随人变化的配置值类型。 */
/** 配置键类型词汇表:真源在 skill-config.mjs(本模块转出口,保持对外 API 不变)。 */
export { CONFIG_TYPES };
const CONFIG_KEY = /^[a-zA-Z][a-zA-Z0-9_-]*$/;
export const repoRoot = () => path.resolve(process.env.PI_CONFIG_REPO || fileURLToPath(new URL('../../../', import.meta.url)));
export const agentDir = repo => path.resolve(process.env.PI_CODING_AGENT_DIR || path.dirname(repo));
export const skillsDir = repo => path.join(repo, 'skills');
export const interfacePath = dir => path.join(dir, 'interface.json');
export const shimDir = () => path.resolve(process.env.PI_SKILL_BIN_DIR || path.join(process.env.HOME || process.env.USERPROFILE || '', '.local', 'bin'));
export const shimOnPath = dir => (process.env.PATH || '').split(path.delimiter).some(entry => entry && path.resolve(entry) === path.resolve(dir));
export const SHIM_MARKER = '由 skill-interface 生成';
const isObject = value => value !== null && typeof value === 'object' && !Array.isArray(value);
const readJson = file => { try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return undefined; } };
/** 技能目录清单(含未声明接口的技能),按名字排序。 */
export function discoverSkills(repo = repoRoot()) {
const root = skillsDir(repo);
if (!fs.existsSync(root)) return [];
return fs.readdirSync(root, { withFileTypes: true })
.filter(entry => entry.isDirectory() && !['vendor', 'node_modules', '__pycache__'].includes(entry.name))
.map(entry => entry.name)
.sort()
.map(name => {
const dir = path.join(root, name);
const skillFile = path.join(dir, 'SKILL.md');
const markdown = fs.existsSync(skillFile) ? fs.readFileSync(skillFile, 'utf8') : '';
const declared = readJson(interfacePath(dir));
return { name, dir, skillFile, markdown, platforms: platformsFromMarkdown(markdown), declared, interface: undefined, errors: [] };
})
.filter(skill => fs.existsSync(skill.skillFile) || skill.declared);
}
/** 校验 interface.json 的声明层(结构、枚举、必填、分级要求);不做文件系统存在性检查。 */
export function validateInterface(value, name) {
const errors = [];
const fail = (check, message, fix) => errors.push({ check, message, fix });
if (!isObject(value)) return [{ check: 'interface-json', message: `${name}: interface.json 必须是 JSON 对象`, fix: '按 REFERENCE.md 结构补全' }];
if (typeof value.summary !== 'string' || !value.summary.trim()) fail('interface-summary', `${name}: 缺少 summary(1 句话说明技能做什么)`, '补 "summary"');
else if (value.summary.length > 140) fail('interface-summary', `${name}: summary 过长(${value.summary.length} > 140)`, '压缩到 1 句');
if (typeof value.useWhen !== 'string' || !value.useWhen.trim()) fail('interface-usewhen', `${name}: 缺少 useWhen(何时用它,供模型选路)`, '补 "useWhen"');
if (!TIERS.includes(value.tier)) fail('interface-tier', `${name}: tier 必须是 0..3`, '例如 "tier": 1');
if (value.entry !== undefined) {
if (!isObject(value.entry)) fail('interface-entry', `${name}: entry 必须是对象`, '用 { "path": "scripts/x.mjs" } 或 { "command": "外部命令" }');
else {
const keys = ['path', 'command'].filter(key => typeof value.entry[key] === 'string' && value.entry[key].trim());
if (keys.length !== 1) fail('interface-entry', `${name}: entry 必须且只能有 path 或 command 之一`, '二选一');
else if (value.entry.path) {
if (path.isAbsolute(value.entry.path)) fail('interface-entry', `${name}: entry.path 必须是技能内相对路径`, '去掉绝对路径前缀');
else if (value.entry.path.split('/').includes('..')) fail('interface-entry', `${name}: entry.path 不得越出技能目录`, '改用技能内路径');
}
}
}
if (value.runner !== undefined && !(typeof value.runner === 'string' && /^[a-z][a-z0-9_-]*$/.test(value.runner))) {
fail('interface-runner', `${name}: runner 必须是可执行名(如 node / python3 / bash / zsh)`, '改写 runner');
}
if (value.aliases !== undefined) {
if (!Array.isArray(value.aliases) || value.aliases.some(alias => typeof alias !== 'string' || !SKILL_NAME.test(alias))) {
fail('interface-aliases', `${name}: aliases 必须是短命令名数组(小写字母/数字/连字符)`, '例如 ["cdpctl"]');
}
}
if (value.admin !== undefined) {
if (!Array.isArray(value.admin)) fail('interface-admin', `${name}: admin 必须是数组`, '每项 { path, summary, alias? }');
else for (const [index, item] of value.admin.entries()) {
if (!isObject(item) || typeof item.path !== 'string' || !item.path.trim()) { fail('interface-admin', `${name}: admin[${index}] 缺 path`, '写技能内相对路径'); continue; }
if (typeof item.summary !== 'string' || !item.summary.trim()) fail('interface-admin', `${name}: admin ${item.path} 缺 summary`, '补 1 句说明(给维护者看)');
if (item.alias !== undefined && !SKILL_NAME.test(item.alias)) fail('interface-admin', `${name}: admin ${item.path} 的 alias 非法`, '小写字母/数字/连字符');
if (path.isAbsolute(item.path) || item.path.split('/').includes('..')) fail('interface-admin', `${name}: admin ${item.path} 必须是技能内相对路径`, '去掉绝对路径/..');
}
}
if (value.errors !== undefined) {
const bad = Array.isArray(value.errors) ? value.errors.filter(code => !ERROR_CODES.includes(code)) : ['<非数组>'];
if (bad.length) fail('interface-errors', `${name}: errors 含未定义错误码 ${bad.join(',')}`, `只用 ${ERROR_CODES.join('/')}`);
}
if (value.dryRun !== undefined && typeof value.dryRun !== 'boolean' && value.dryRun !== 'external') fail('interface-dryrun', `${name}: dryRun 必须是 true / false / "external"`, '外部工具无法干跑时用 "external"');
if (value.liveness !== undefined && !(typeof value.liveness === 'string' && /^[a-z][a-z0-9_]*$/.test(value.liveness))) fail('interface-liveness', `${name}: liveness 必须是 status 输出里的字段名(小写字母/数字/下划线)`, '例如 "liveness": "last_sample"');
if (value.helpListsCommands !== undefined && typeof value.helpListsCommands !== 'boolean') fail('interface-helplist', `${name}: helpListsCommands 必须是布尔值`, 'true/false');
if (value.probeUnknownCommand !== undefined && typeof value.probeUnknownCommand !== 'boolean') fail('interface-probe', `${name}: probeUnknownCommand 必须是布尔值`, 'true/false');
const commands = value.commands;
if (commands !== undefined && !Array.isArray(commands)) fail('interface-commands', `${name}: commands 必须是数组`, '每项 { name, summary, destructive? }');
else for (const [index, command] of (commands ?? []).entries()) {
const label = `${name}: commands[${index}]`;
if (!isObject(command) || typeof command.name !== 'string' || !SKILL_NAME.test(command.name)) { fail('interface-command-name', `${label} 的 name 非法`, '小写字母/数字/连字符'); continue; }
if (typeof command.summary !== 'string' || !command.summary.trim()) fail('interface-command-summary', `${name}: 命令 ${command.name} 缺少 summary`, '补 1 句说明');
if (command.destructive !== undefined && typeof command.destructive !== 'boolean') fail('interface-command-destructive', `${name}: 命令 ${command.name} 的 destructive 必须是布尔值`, 'true/false');
}
const duplicates = (commands ?? []).map(command => command?.name).filter((v, i, all) => v && all.indexOf(v) !== i);
if (duplicates.length) fail('interface-command-duplicate', `${name}: 命令名重复 ${[...new Set(duplicates)].join(',')}`, '去掉重复项');
// dependsOn:硬依赖(用到的面 = 子集式声明,只有声明才有契约;事实由 skill-deps 静态反查对账)
if (value.dependsOn !== undefined) {
if (!Array.isArray(value.dependsOn)) fail('interface-deps', `${name}: dependsOn 必须是数组`, '每项 { skill, commands?, tierAtLeast? }');
else for (const [index, dep] of value.dependsOn.entries()) {
const label = `${name}: dependsOn[${index}]`;
if (!isObject(dep) || typeof dep.skill !== 'string' || !SKILL_NAME.test(dep.skill)) { fail('interface-deps', `${label} 缺合法的 skill 名`, '小写字母/数字/连字符,例如 "dmp-submit"'); continue; }
if (dep.skill === name) fail('interface-deps', `${label} 不能依赖自己`, '去掉自引用');
const badKeys = Object.keys(dep).filter(key => !['skill', 'commands', 'tierAtLeast', 'note'].includes(key));
if (badKeys.length) fail('interface-deps', `${label} 有未定义字段 ${badKeys.join(',')}`, '只用 skill / commands / tierAtLeast / note');
if (dep.commands !== undefined && (!Array.isArray(dep.commands) || dep.commands.some(item => typeof item !== 'string' || !SKILL_NAME.test(item)))) {
fail('interface-deps', `${label}.commands 必须是对端命令名数组`, '例如 ["submit"]');
}
if (dep.tierAtLeast !== undefined && !TIERS.includes(dep.tierAtLeast)) fail('interface-deps', `${label}.tierAtLeast 必须是 0..3`, '例如 "tierAtLeast": 1');
if (dep.note !== undefined && typeof dep.note !== 'string') fail('interface-deps', `${label}.note 必须是字符串`, '去掉 note 或写成一句话');
}
}
// requires:外部环境(程序/运行时/外部系统/账号);运行时的体检在 check 命令,声明在这里
if (value.requires !== undefined) {
if (!Array.isArray(value.requires)) fail('interface-requires', `${name}: requires 必须是数组`, '每项 { kind, name, min?, install?, check?, note? }');
else for (const [index, item] of value.requires.entries()) {
const label = `${name}: requires[${index}]`;
if (!isObject(item) || !REQUIRE_KINDS.includes(item.kind)) { fail('interface-requires', `${label}.kind 必须是 ${REQUIRE_KINDS.join('/')}`, 'binary=外部程序 / runtime=运行时 / service=外部系统 / account=账号'); continue; }
if (typeof item.name !== 'string' || !item.name.trim()) { fail('interface-requires', `${label} 缺 name`, '写程序名 / 服务名 / 账号名'); continue; }
for (const key of ['min', 'install', 'check', 'note']) {
if (item[key] !== undefined && (typeof item[key] !== 'string' || !item[key].trim())) fail('interface-requires', `${label}.${key} 必须是非空字符串`, '改成文本,或删掉');
}
const badKeys = Object.keys(item).filter(key => !['kind', 'name', 'min', 'install', 'check', 'note'].includes(key));
if (badKeys.length) fail('interface-requires', `${label} 有未定义字段 ${badKeys.join(',')}`, '只用 kind / name / min / install / check / note');
}
}
// config:随人/随机器变化的值(谁需要提前设置、怎么问、是否密钥);读取原语见 ROADMAP Phase 3
if (value.config !== undefined) {
if (!Array.isArray(value.config)) fail('interface-config', `${name}: config 必须是数组`, '每项 { key, desc, type?, required?, requiredFor?, discover?, ask?, secret?, default? }');
else {
const declaredCommands = (commands ?? []).map(command => command?.name).filter(Boolean);
const knownKeys = new Set();
for (const [index, item] of value.config.entries()) {
if (!isObject(item) || typeof item.key !== 'string' || !CONFIG_KEY.test(item.key)) { fail('interface-config', `${name}: config[${index}] 的 key 非法`, '小写字母开头,只用小写字母/数字/下划线/连字符'); continue; }
const label = `${name}: config.${item.key}`;
if (knownKeys.has(item.key)) fail('interface-config', `${name}: config 键重复 ${item.key}`, '去掉重复项');
knownKeys.add(item.key);
if (typeof item.desc !== 'string' || !item.desc.trim()) fail('interface-config', `${label} 缺 desc`, '给人和模型看的一句话说明');
if (item.type !== undefined && !CONFIG_TYPES.includes(item.type)) fail('interface-config', `${label} 的 type 非法`, `只用 ${CONFIG_TYPES.join('/')}`);
if ((item.type ?? 'string') === 'enum' && (!Array.isArray(item.values) || !item.values.length)) fail('interface-config', `${label} type=enum 需要非空 values`, '给可选值数组');
for (const key of ['required', 'secret']) if (item[key] !== undefined && typeof item[key] !== 'boolean') fail('interface-config', `${label}.${key} 必须是布尔值`, 'true/false');
if (item.requiredFor !== undefined) {
if (!Array.isArray(item.requiredFor) || item.requiredFor.some(command => typeof command !== 'string')) fail('interface-config', `${label}.requiredFor 必须是命令名数组`, '例如 ["run"]');
else for (const command of item.requiredFor) if (!declaredCommands.includes(command)) fail('interface-config', `${label}.requiredFor 引用了未声明的命令 ${command}`, '先在 commands 里声明该命令,或删掉这一项');
}
if (item.secret === true && item.default !== undefined) fail('interface-config', `${label} 是 secret,不得带字面量默认值`, '删掉 default,改由 setup 或环境变量提供');
for (const key of ['discover', 'ask', 'note']) if (item[key] !== undefined && (typeof item[key] !== 'string' || !item[key].trim())) fail('interface-config', `${label}.${key} 必须是非空字符串`, '改成文本,或删掉');
if (item.setupFlag !== undefined && (typeof item.setupFlag !== 'string' || !/^--[A-Za-z0-9][A-Za-z0-9-]*$/.test(item.setupFlag))) fail('interface-config', `${label}.setupFlag 必须是 --flag 形式`, '技能的 setup 命令用了不同旗标时才写,例如 --url');
if (item.ttl !== undefined && (!Number.isFinite(item.ttl) || item.ttl <= 0)) fail('interface-config', `${label}.ttl 必须是正数(秒)`, '例如 86400;不需要过期检查就删掉');
if (item.when !== undefined) {
const platforms = item.when?.platform;
if (!isObject(item.when) || (platforms !== undefined && (!Array.isArray(platforms) || !platforms.length || platforms.some(value => typeof value !== 'string')))) {
fail('interface-config', `${label}.when 形状不对`, '例如 { "when": { "platform": ["win32","linux"] } }(只在列出的平台上才要求这个值)');
}
}
const badKeys = Object.keys(item).filter(key => !['key', 'desc', 'type', 'values', 'required', 'requiredFor', 'discover', 'ask', 'secret', 'default', 'note', 'ttl', 'when', 'setupFlag'].includes(key));
if (badKeys.length) fail('interface-config', `${label} 有未定义字段 ${badKeys.join(',')}`, '只用 key/desc/type/values/required/requiredFor/discover/ask/secret/default/note/ttl/when/setupFlag');
}
}
}
if (value.status !== undefined && !SKILL_STATUSES.includes(value.status)) fail('interface-status', `${name}: status 必须是 ${SKILL_STATUSES.join('/')}`, '省略表示 stable;废弃用 "deprecated"');
const destructive = (commands ?? []).filter(command => command?.destructive === true);
if (value.tier >= 1 && destructive.length && value.dryRun !== true && value.dryRun !== 'external') {
fail('contract-dryrun', `${name}: 含破坏性命令(${destructive.map(c => c.name).join(',')})但未声明 dryRun`, '在接口里加 "dryRun": true,或 "external" + 文档风险说明');
}
if (value.tier === 2 || (value.tier === 3 && value.lifecycle !== undefined)) {
const missing = LIFECYCLE.filter(item => !Array.isArray(value.lifecycle) || !value.lifecycle.includes(item));
if (missing.length) fail('contract-lifecycle', `${name}: 常驻型(T${value.tier} 且声明了 lifecycle)必须齐四件套,缺 ${missing.join(',')}`, `"lifecycle": [${LIFECYCLE.map(v => `"${v}"`).join(', ')}]`);
}
if (value.tier === 0 && value.entry !== undefined && !(value.commands ?? []).length) {
fail('interface-commands', `${name}: 声明了 entry 但未声明任何命令(纯旗标工具请显式写 "commands": [] 并在文档里给用法)`, '补 commands,或确认是旗标工具后保持空数组并补文档');
}
return errors;
}
/** 发现 + 校验 + 解析入口,得到可直接执行的技能记录。 */
export function loadSkills(repo = repoRoot()) {
return discoverSkills(repo).map(skill => {
const errors = [];
if (skill.declared === undefined) {
if (skill.markdown) errors.push({ check: 'interface-json', message: `${skill.name}: 缺少 interface.json`, fix: `新建 ${path.relative(repo, interfacePath(skill.dir))}(见 REFERENCE.md)` });
else errors.push({ check: 'skill-md', message: `${skill.name}: 既无 SKILL.md 也无 interface.json`, fix: '删除空目录或补文档' });
} else {
errors.push(...validateInterface(skill.declared, skill.name));
}
const resolved = errors.length ? undefined : resolveEntry(skill);
if (resolved?.errors?.length) errors.push(...resolved.errors.map(error => ({ ...error, message: `${skill.name}: ${error.message}` })));
const admin = resolved ? adminEntries(skill) : { items: [], errors: [] };
for (const error of admin.errors) errors.push({ ...error, message: `${skill.name}: ${error.message}` });
return { ...skill, errors, entry: resolved?.entry, admin: admin.items, docOnly: skill.declared && skill.declared.entry === undefined };
});
}
/** 单个脚本文件 → argv(runner 优先声明,其次扩展名,其次要求可执行位)。 */
export function resolveScript(skillDir, relative, runner) {
const file = path.join(skillDir, relative);
if (!fs.existsSync(file)) return { errors: [{ check: 'entry-exists', message: `文件不存在:${relative}`, fix: '修正路径' }] };
const resolvedRunner = runner || RUNNERS[path.extname(file)];
if (!resolvedRunner) {
if ((fs.statSync(file).mode & 0o111) === 0) return { errors: [{ check: 'entry-runnable', message: `无法执行 ${relative}:未知扩展名且未声明 runner,也没有执行位`, fix: '声明 "runner",或 chmod +x' }] };
return { entry: { kind: 'path', file, argv: [file], label: relative } };
}
return { entry: { kind: 'path', file, runner: resolvedRunner, argv: [resolvedRunner, file], label: relative } };
}
/** 接口 → argv。runner 优先取声明,其次按扩展名,其次要求文件本身可执行。 */
export function resolveEntry(skill) {
const spec = skill.declared?.entry;
if (!spec) return { errors: [] };
if (spec.command) return { entry: { kind: 'command', command: spec.command, argv: [spec.command], label: spec.command } };
return resolveScript(skill.dir, spec.path, skill.declared.runner);
}
/** 运维入口(不经模型):带 alias 的可注册为 PATH 命令。 */
export function adminEntries(skill) {
const items = [];
const errors = [];
for (const item of skill.declared?.admin ?? []) {
if (!item.alias) continue;
const resolved = resolveScript(skill.dir, item.path, item.runner);
if (resolved.errors?.length) errors.push(...resolved.errors);
else items.push({ alias: item.alias, argv: resolved.entry.argv, label: item.path });
}
return { items, errors };
}
/** PATH 入口(shim)内容:只做 exec 转发,不含业务逻辑。 */
export function shimText(entry, platform = process.platform) {
const quote = value => `'${String(value).replaceAll("'", `'\\''`)}'`;
if (platform === 'win32') {
const command = entry.argv.map(value => (/[\s"]/.test(value) ? `"${String(value).replaceAll('"', '\\"')}"` : value)).join(' ');
return `@echo off\r\nrem ${SHIM_MARKER}:请勿手改(改 interface.json 后重新 install)\r\n${command} %*\r\n`;
}
const argv = entry.argv.map(quote).join(' ');
return `#!/bin/sh\n# ${SHIM_MARKER}:请勿手改(改 interface.json 后重新 install)\nexec ${argv} "$@"\n`;
}
/** 入口文件名:Windows 用 .cmd(npm/PATH 语义),其它平台原名。 */
export const shimName = (alias, platform = process.platform) => (platform === 'win32' ? `${alias}.cmd` : alias);
/** 入口文件名 → 别名(win32 去掉 .cmd)。 */
export const aliasOf = (name, platform = process.platform) => (platform === 'win32' && name.endsWith('.cmd') ? name.slice(0, -4) : name);
/** 执行入口;返回 {status, stdout, stderr, ms, timeout}。 */
export function runEntry(entry, args = [], options = {}) {
const started = Date.now();
const result = spawnSync(entry.argv[0], [...entry.argv.slice(1), ...args], {
cwd: options.cwd || process.cwd(),
encoding: 'utf8',
timeout: options.timeoutMs ?? 30_000,
maxBuffer: options.maxBuffer ?? 8 * 1024 * 1024,
env: { ...process.env, ...(options.env || {}) },
stdio: options.stdio ?? 'pipe',
});
const timeout = result.error?.code === 'ETIMEDOUT' || result.signal === 'SIGTERM';
return { status: result.status, signal: result.signal, stdout: result.stdout ?? '', stderr: result.stderr ?? '', error: result.error, timeout, ms: Date.now() - started };
}
/** 技能是否声明了该命令(用于帮助文本一致性判断)。 */
export const declaresCommand = (skill, name) => (skill.declared?.commands ?? []).some(command => command?.name === name);

View File

@ -0,0 +1,362 @@
#!/usr/bin/env node
/**
* 发布技能的两个出口(同一个源,同一道门禁;镜像仓/包仓都是生成物):
*
* skill-release cli <技能> 发布 CLI 仓:<工具>-cli(给所有人,不依赖 Pi)
* skill-release package <技能> 发布 Pi package 仓:<工具>(pi install git:… 一条命令装技能)
* skill-release list 清点两个出口的发布状态
*
* 门禁复用 skill-pack(白名单 / 泄漏扫描 / skillcheck 结构 / 契约不漂移),并且
* 只在技能目录已提交(没有未提交改动)时发布——记账的提交要对得上发布内容。
*
* 声明(interface.json):`"publish": { "repo": "<CLI 仓 URL>", "mirrors"?: ["…"],
* "package_repo"?: "<包仓 URL>" }`;包仓默认由 CLI 仓名去掉 `-cli` 得到(命名约定)。
*
* 载荷:
* cli = scripts/ + tests/ + VERSION + REFERENCE.md + 生成的 README.md + MANIFEST.json
* (纯工具,不带 Pi 元数据)
* package = skills/<技能>/(skill-pack 的技能本体)+ 生成的 package.json + README.md + MANIFEST.json
*
* 退出码:0 成功;3 门禁被拦或全部推送失败;2 调用错误。
*/
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { spawnSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { packSkill, scanLeaks } from './skill-pack.mjs';
import { loadSkills, repoRoot, agentDir } from './skill-registry.mjs';
export const EXIT_OK = 0;
export const EXIT_USAGE = 2;
export const EXIT_BLOCKED = 3;
const PI_METADATA = ['SKILL.md', 'interface.json', 'contract.lock.json'];
const git = (cwd, args) => {
const result = spawnSync('git', ['-C', cwd, ...args], { encoding: 'utf8' });
return { ok: result.status === 0, out: (result.stdout ?? '').trim(), err: (result.stderr ?? '').trim() };
};
export const stateFile = repo => path.join(agentDir(repo), 'local', 'skills', 'skill-interface', 'release.json');
export function readState(repo) {
for (const name of ['release.json', 'publish.json']) {
try {
return JSON.parse(fs.readFileSync(path.join(agentDir(repo), 'local', 'skills', 'skill-interface', name), 'utf8'));
} catch {
/* 换名前的记账在 publish.json,读到就沿用 */
}
}
return {};
}
function writeState(repo, state) {
const file = stateFile(repo);
fs.mkdirSync(path.dirname(file), { recursive: true });
const tmp = `${file}.tmp`;
fs.writeFileSync(tmp, `${JSON.stringify(state, null, 2)}\n`);
fs.renameSync(tmp, file);
}
export const versionOf = skill => {
try {
return fs.readFileSync(path.join(skill.dir, 'VERSION'), 'utf8').trim();
} catch {
return '';
}
};
/** CLI 仓名 → 包仓名:去掉 `-cli`(命名约定)。 */
export const packageRepoOf = cliRepo => cliRepo.replace(/-cli(\.git)?$/, '$1');
export const releaseDeclaration = skill => {
const declared = skill.declared?.publish;
const cliRepo = typeof declared?.repo === 'string' ? declared.repo.trim() : '';
const packageOnly = typeof declared?.package_repo === 'string' ? declared.package_repo.trim() : '';
if (!declared || (!cliRepo && !packageOnly)) return null;
const cli = [cliRepo, ...(Array.isArray(declared.mirrors) ? declared.mirrors : [])]
.filter(value => typeof value === 'string' && value.trim())
.map(value => value.trim());
const packageRepo = packageOnly || (cliRepo ? packageRepoOf(cliRepo) : '');
return { cli, packageRepo, version: versionOf(skill) };
};
/** 技能目录在共享仓库里的最后一次提交;list 用它判断「发布后又改过」。 */
export const subtreeCommit = (repo, skill) => {
const relative = path.relative(repo, skill.dir) || '.';
return git(repo, ['log', '-1', '--format=%H', '--', relative]).out;
};
function outletStatus(record, declaration, commit) {
if (!record) return 'unpublished';
if (record.commit && commit && record.commit !== commit) return 'changed';
if (record.version !== declaration.version) return 'version-changed';
return 'published';
}
export function listReleases(repo = repoRoot()) {
const state = readState(repo);
return loadSkills(repo)
.filter(skill => releaseDeclaration(skill))
.map(skill => {
const declaration = releaseDeclaration(skill);
const record = state[skill.name] ?? {};
const commit = subtreeCommit(repo, skill);
return {
name: skill.name,
version: declaration.version,
cli: {
repo: declaration.cli[0],
mirrors: declaration.cli.slice(1),
status: outletStatus(record.cli, declaration, commit),
published_at: record.cli?.at ?? null,
published_version: record.cli?.version ?? null,
},
package: {
repo: declaration.packageRepo,
status: outletStatus(record.package, declaration, commit),
published_at: record.package?.at ?? null,
published_version: record.package?.version ?? null,
},
};
});
}
function cliReadme(skill, declaration) {
const name = skill.name;
return `# ${path.basename(declaration.cli[0].replace(/\.git$/, ''))}
${skill.declared?.summary ?? name}
由 Pi 技能 \`${name}\` 发布(同一个源还有技能包出口),当前版本 v${declaration.version}。
命令名是 \`${name}\`:仓库名带 \`-cli\`,命令不带。
## 安装
\`\`\`sh
./scripts/install-cli.sh # 链接到 ~/.local/bin/${name} 并检查 node/mpv/python3
${name} check # 自检:依赖 / 登录
${name} login # 未登录时(手机扫码)
${name} help # 完整命令面
\`\`\`
消费方契约(JSON 字段 / 环境变量 / 状态目录 / 退出码)见 [REFERENCE.md](REFERENCE.md)。
## 许可
见仓库内的许可声明(与 Pi 技能 \`${name}\` 相同)。
`;
}
function packageReadme(skill, declaration) {
const name = skill.name;
const url = declaration.packageRepo;
const cliNote = declaration.cli.length
? `;同一个源还有 CLI 出口 \`${path.basename(declaration.cli[0].replace(/\.git$/, ''))}\``
: '';
return `# ${path.basename(url.replace(/\.git$/, ''))}
${skill.declared?.summary ?? name}
以 Pi package(技能形态)发布${cliNote}。
技能本体在 \`skills/${name}/\`,用法见其 \`SKILL.md\` 与 \`REFERENCE.md\`。
## 安装
\`\`\`sh
pi install git:${url.replace(/^https?:\/\//, '')}@v${declaration.version}
\`\`\`
装完 \`pi-skill list\` 能看到 \`${name}\`,\`pi-skill ${name} check\` 会告诉还缺什么。
`;
}
const packageJsonOf = (skill, declaration) => ({
name: skill.name,
version: declaration.version,
description: skill.declared?.summary ?? '',
});
/** 领域文件:打包白名单 + 技能自己的 tests/(tests 也过一遍泄漏扫描)。 */
const extraFiles = skill => (fs.existsSync(path.join(skill.dir, 'tests')) ? ['tests'] : []);
/**
* 发布 SKILL 的一个出口(outlet: 'cli' | 'package')。
* 返回 { ok, blocked[], outlet, remotes, version }。
*/
export function releaseOutlet(skill, { repo = repoRoot(), outlet = 'cli', dryRun = false } = {}) {
const declaration = releaseDeclaration(skill);
if (!declaration) throw new Error(`技能 ${skill.name} 没有 publish 声明`);
if (outlet === 'cli' && !declaration.cli.length) throw new Error(`技能 ${skill.name} 只声明了包仓(package_repo),没有 CLI 仓可发`);
if (outlet === 'package' && !declaration.packageRepo) throw new Error(`技能 ${skill.name} 没有包仓(publish.package_repo 或由 CLI 仓去 -cli 推导)`);
if (!declaration.version) throw new Error(`技能 ${skill.name} 缺少 VERSION(发布用它打 tag)`);
const tag = `v${declaration.version}`;
// 发布的是可复现状态:技能目录有未提交改动先拦住(否则记账的提交对不上发布内容)。
const relative = path.relative(repo, skill.dir) || '.';
const dirty = git(repo, ['status', '--porcelain', '--', relative]).out;
if (dirty) {
return { ok: false, blocked: [`技能目录有未提交改动:先提交再发布(${dirty.split('\n').length} 个文件)`], remotes: [], outlet, version: declaration.version };
}
const extra = outlet === 'cli' ? extraFiles(skill) : [];
const leaks = scanLeaks(skill.dir, extra).filter(leak => leak.level === 'error');
if (leaks.length) {
return { ok: false, blocked: [`tests/ 泄漏扫描 error:${leaks.map(leak => `${leak.file}:${leak.line}`).join(', ')}`], remotes: [], outlet, version: declaration.version };
}
const packOut = fs.mkdtempSync(path.join(os.tmpdir(), 'skill-release-pack-'));
const packed = packSkill(skill, { outDir: packOut });
if (!packed.ok) return { ok: false, blocked: packed.blocked, remotes: [], outlet, version: declaration.version };
// 组装载荷目录(生成的 README/package.json 也在里面,整体覆盖镜像仓)。
const payload = fs.mkdtempSync(path.join(os.tmpdir(), 'skill-release-payload-'));
if (outlet === 'cli') {
// CLI 仓 = 纯工具:打包内容去掉 Pi 元数据,再加 tests 与 README。
fs.cpSync(packed.dir, payload, { recursive: true });
for (const file of PI_METADATA) fs.rmSync(path.join(payload, file), { force: true });
for (const name of extra) fs.cpSync(path.join(skill.dir, name), path.join(payload, name), { recursive: true });
fs.writeFileSync(path.join(payload, 'README.md'), cliReadme(skill, declaration));
} else {
// 包仓 = 技能形态:技能本体进 skills/<技能>/(Pi 按约定目录发现),根上只有
// package.json / README / MANIFEST(溯源),不把工具文件摊在根目录。
const skillDir = path.join(payload, 'skills', skill.name);
fs.mkdirSync(path.dirname(skillDir), { recursive: true });
fs.cpSync(packed.dir, skillDir, { recursive: true });
fs.rmSync(path.join(skillDir, 'MANIFEST.json'), { force: true });
fs.cpSync(path.join(packed.dir, 'MANIFEST.json'), path.join(payload, 'MANIFEST.json'));
fs.writeFileSync(path.join(payload, 'package.json'), `${JSON.stringify(packageJsonOf(skill, declaration), null, 2)}\n`);
fs.writeFileSync(path.join(payload, 'README.md'), packageReadme(skill, declaration));
}
const remotes = outlet === 'cli' ? declaration.cli : [declaration.packageRepo];
const results = [];
for (const remote of remotes) {
const work = fs.mkdtempSync(path.join(os.tmpdir(), 'skill-release-work-'));
const cloned = git(work, ['clone', '--quiet', remote, work]);
const result = { url: remote, ok: false, message: '' };
if (!cloned.ok) result.message = '仓库不存在或无法访问:先在 gitea 建同名空仓(或检查凭据/网络)';
else {
for (const entry of fs.readdirSync(work)) {
if (entry !== '.git') fs.rmSync(path.join(work, entry), { recursive: true, force: true });
}
fs.cpSync(payload, work, { recursive: true });
git(work, ['add', '-A']);
// MANIFEST 带打包时刻,属易变元数据:只有真实内容变了才算「新版本内容」。
const changed = git(work, ['status', '--porcelain']).out
.split('\n')
.filter(line => line && !line.endsWith('MANIFEST.json'))
.join('\n');
const hasTag = git(work, ['rev-parse', '-q', '--verify', `refs/tags/${tag}`]).ok;
if (changed && hasTag) {
result.message = `${tag} 已发布过不同内容:抬 VERSION 再发(版本号已发布就不再改写)`;
} else if (dryRun) {
result.ok = true;
result.message = `dry-run:将推送 ${changed ? '新提交' : '无变更'} + tag ${tag}`;
} else {
if (changed) git(work, ['commit', '-q', '-m', `release ${tag}`]);
if (!hasTag) git(work, ['tag', tag]);
const pushHead = git(work, ['push', '--quiet', 'origin', 'HEAD:main']);
const pushTag = pushHead.ok && (hasTag || git(work, ['push', '--quiet', 'origin', `refs/tags/${tag}`]).ok);
result.ok = pushHead.ok && pushTag;
result.message = result.ok ? '已推送' : (pushHead.err || '推送失败').split('\n')[0];
}
if (!dryRun && result.ok) result.commit = git(work, ['rev-parse', 'HEAD']).out;
}
fs.rmSync(work, { recursive: true, force: true });
results.push(result);
}
fs.rmSync(payload, { recursive: true, force: true });
fs.rmSync(packOut, { recursive: true, force: true });
const ok = results[0]?.ok ?? false;
if (!dryRun && ok) {
const state = readState(repo);
const record = state[skill.name] ?? {};
// 每个出口各记自己的发布提交:先发 CLI、改完再发包,两条状态互不掩盖。
record[outlet] = {
repo: results[0].url,
mirrors: outlet === 'cli' ? declaration.cli.slice(1) : [],
version: declaration.version,
commit: subtreeCommit(repo, skill),
mirror_commit: results[0].commit ?? null,
at: new Date().toISOString(),
};
state[skill.name] = record;
writeState(repo, state);
}
return { ok, blocked: ok ? [] : results.map(item => `${item.url}:${item.message}`), outlet, remotes: results, version: declaration.version };
}
// ---- CLI ----
const USAGE = `skill-release — 发布技能的两个出口(同一个源,同一道门禁)
skill-release cli <技能> [--dry-run] [--json] 发布 CLI 仓 <工具>-cli(给所有人)
skill-release package <技能> [--dry-run] [--json] 发布 Pi package 仓 <工具>(pi install 一条命令装)
skill-release list [--json] 清点两个出口的发布状态
声明:interface.json 的 \`publish\`(repo / mirrors? / package_repo?);仓名约定 <工具>-cli 与 <工具>。
门禁:skill-pack(白名单/泄漏/skillcheck+契约)+ 技能目录必须已提交;版本号已发布就不再改写。
退出码:0 成功;3 门禁被拦或全部推送失败;2 调用错误。`;
const isMain = (() => {
try {
return Boolean(process.argv[1]) && fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url));
} catch {
return false;
}
})();
if (isMain) {
const args = process.argv.slice(2);
const json = args.includes('--json');
const dryRun = args.includes('--dry-run');
const positional = args.filter(arg => !arg.startsWith('-'));
const [target, name] = positional;
const repo = repoRoot();
const usageExit = message => {
console.error(`error=usage ${message}`);
console.error('hint=skill-release help');
process.exit(EXIT_USAGE);
};
if (!target || target === 'help' || target === '--help' || target === '-h') {
console.log(USAGE);
process.exit(EXIT_OK);
}
if (target === 'list') {
const rows = listReleases(repo);
if (json) console.log(JSON.stringify({ skills: rows }, null, 2));
else if (!rows.length) console.log('没有技能声明 publish(interface.json 的 publish.repo)');
else
for (const row of rows) {
console.log(`${row.name} v${row.version}`);
console.log(` cli ${row.cli.status.padEnd(15)} ${row.cli.repo}${row.cli.published_at ? ` (上次 ${row.cli.published_at.slice(0, 16)})` : ''}`);
console.log(` package ${row.package.status.padEnd(15)} ${row.package.repo}`);
}
process.exit(EXIT_OK);
}
if (target !== 'cli' && target !== 'package') usageExit(`第一个参数必须是 cli | package | list,收到「${target}」`);
if (!name) usageExit(`skill-release ${target} 需要技能名`);
const skill = loadSkills(repo).find(item => item.name === name);
if (!skill) usageExit(`没有技能「${name}」(skill-release list 看可发布的技能)`);
if (!skill.declared?.publish) usageExit(`技能「${name}」没有声明 publish.repo(在 interface.json 里加)`);
try {
const result = releaseOutlet(skill, { repo, outlet: target, dryRun });
if (json) console.log(JSON.stringify(result, null, 2));
else {
console.log(`release ${target} ${skill.name} v${result.version}${dryRun ? '(dry-run)' : ''}`);
for (const remote of result.remotes) console.log(` ${remote.ok ? 'ok ' : 'fail'} ${remote.url} ${remote.message}`);
if (!result.ok) for (const line of result.blocked) console.log(` blocked: ${line}`);
}
process.exit(result.ok ? EXIT_OK : EXIT_BLOCKED);
} catch (problem) {
console.error(`error=internal ${problem.message}`);
console.error(`hint=skill-release list;门禁细节跑 skill-pack ${name} --dry-run --json`);
process.exit(EXIT_BLOCKED);
}
}

View File

@ -0,0 +1,164 @@
#!/usr/bin/env node
/**
* skill-retire — 退役流程:把不再单独维护的技能变成「指路牌」。
*
* 用法:
* skill-retire <技能> --to <替代技能> [--days 30] [--force] [--apply] [--json]
*
* 设计(见 ROADMAP P6.2 / REFERENCE §13):
* - 旧技能不删除:入口换成退役 shim,调用者拿到 `error=notfound` + 替代技能的 hint;
* commands 清空、status 置 deprecated,别名保留(老提示文案里的名字仍能定位到指路牌)。
* - 前置门禁:① 没有硬依赖(谁在脚本里真调用它);② 近 N 天 0 调用。
* ①不可越过(先改调用方),②可用 --force 越过(例如本人刚用过一次)。
* - 默认只预览(--dry-run 同义),--apply 才落盘;落盘后同步契约基线。
*
* 退出码:0 成功 / 2 调用错误 / 3 门禁未通过。
*/
import fs from 'node:fs';
import path from 'node:path';
import { loadSkills, repoRoot } from './skill-registry.mjs';
import { consumersOf, dependencyGraph } from './skill-deps.mjs';
import { readInvocations, summarize } from './skill-invocation-log.mjs';
import { writeContract } from './skill-contract.mjs';
const USAGE = `skill-retire — 技能退役(默认预览,--apply 落盘)
skill-retire <技能> --to <替代技能> [--days 30] [--force] [--apply] [--json]
--to <技能> 接替它的技能(必填;两边的名字都要在技能表里)
--days N 调用量窗口,默认 30 天
--force 越过「近 N 天有调用」门禁(硬依赖永远不能越过)
--apply 真落盘(默认只打印计划)
--json 机器可读输出
落盘内容:把该技能自己的入口文件换成指路牌(entry 缺省时才生成 scripts/retired.mjs),只报 error=notfound + hint=替代技能;
commands 清空、status=deprecated、别名保留、契约基线同步更新、SKILL.md 顶部加退役说明。`;
const args = process.argv.slice(2);
if (args.includes('help') || args.includes('-h') || args.includes('--help')) { console.log(USAGE); process.exit(0); }
const asJson = args.includes('--json');
const apply = args.includes('--apply');
const force = args.includes('--force');
const toIndex = args.indexOf('--to');
const target = args.find(value => !value.startsWith('-'));
const replacementName = toIndex !== -1 ? args[toIndex + 1] : null;
const daysIndex = args.indexOf('--days');
const daysRaw = daysIndex !== -1 ? args[daysIndex + 1] : '30';
const allowed = new Set(['--json', '--apply', '--force', '--to', '--days', 'help', '-h', '--help']);
const bad = args.filter((value, index) => value.startsWith('-') && !allowed.has(value) && args[index - 1] !== '--to' && args[index - 1] !== '--days');
const fail = (message, hint, exitCode = 2) => { console.error(`error=usage ${message}\nhint=${hint}`); process.exit(exitCode); };
if (bad.length) fail(`不认识的参数 ${bad.join(' ')}`, 'skill-retire help');
if (!/^\d+$/.test(daysRaw)) fail('--days 只接受天数', 'skill-retire <技能> --to <替代> --days 30');
if (!target || !replacementName) fail('需要「技能 + 替代技能」两个名字', 'skill-retire <技能> --to <替代技能>');
const repo = repoRoot();
const skills = loadSkills(repo);
const skill = skills.find(candidate => candidate.name === target || (candidate.declared?.aliases ?? []).includes(target));
if (!skill) fail(`没有技能「${target}」`, 'pi-skill list', 2);
const replacement = skills.find(candidate => candidate.name === replacementName || (candidate.declared?.aliases ?? []).includes(replacementName));
if (!replacement) fail(`没有替代技能「${replacementName}」`, 'pi-skill list', 2);
if (replacement.name === skill.name) fail('替代技能不能是自己', 'skill-retire <技能> --to <另一个技能>');
const graph = dependencyGraph(skills);
const consumers = consumersOf(graph, skill.name);
const days = Number(daysRaw);
const records = readInvocations({ since: days === 0 ? 0 : Date.now() - days * 86400000 });
const summary = summarize(records, { skills });
const calls = summary.skills.find(row => row.name === skill.name)?.calls ?? 0;
const blockers = [];
if (consumers.hard.length) blockers.push({ gate: 'hard-consumers', detail: consumers.hard.join(', ') });
if (calls > 0 && !force) blockers.push({ gate: 'recent-calls', detail: `${calls} 次(${days} 天内)` });
const entryPath = skill.declared?.entry?.path ?? 'scripts/retired.mjs';
const shimRel = entryPath.startsWith('scripts/') ? entryPath : 'scripts/retired.mjs';
const shimSource = `#!/usr/bin/env node
// 退役指路牌(由 skill-retire 生成):老名字仍可调用,但只会告诉你新家在哪。
console.error('error=notfound 技能 ${skill.name} 已退役,改用 ${replacement.name}');
console.error('hint=pi-skill ${replacement.name} help');
process.exit(1);
`;
const nextDeclared = {
...skill.declared,
summary: `【已退役】${String(skill.declared?.summary ?? '').replace(/^【已退役】/, '')} → 用 ${replacement.name}`,
status: 'deprecated',
entry: { ...(skill.declared?.entry ?? {}), path: shimRel },
commands: [],
// 别名原样保留:不主动补「技能名」入口(`pi-skill <技能名>` 由统一入口解析,不依赖 PATH shim;
// 凭空多出别名会让新环境的 skill-entry-install 立刻报 alias-missing)
...(skill.declared?.aliases ? { aliases: skill.declared.aliases } : {}),
};
delete nextDeclared.helpListsCommands;
nextDeclared.errors = [...new Set([...(skill.declared?.errors ?? []), 'notfound'])];
const aliasEntries = (skill.declared?.admin ?? []).map(item => ({ ...item }));
if (aliasEntries.length) nextDeclared.admin = aliasEntries;
const docPath = path.join(skill.dir, 'SKILL.md');
const docOld = fs.existsSync(docPath) ? fs.readFileSync(docPath, 'utf8') : '';
const noteLine = `> **已退役**:本技能并入 \`${replacement.name}\`。请改用 \`pi-skill ${replacement.name} help\`(老名字仍会指到这里)。\n`;
const docNew = docOld.includes('已退役') ? docOld : insertAfterFrontMatter(docOld, noteLine);
/** 插在 YAML front matter 之后(没有 front matter 就放最前)。 */
function insertAfterFrontMatter(text, line) {
if (!text.startsWith('---\n')) return line + '\n' + text;
const end = text.indexOf('\n---', 4);
if (end === -1) return line + '\n' + text;
const cut = text.indexOf('\n', end + 1);
return `${text.slice(0, cut + 1)}\n${line}${text.slice(cut + 1)}`;
}
const plan = {
skill: skill.name,
to: replacement.name,
applied: false,
calls_in_window: calls,
hard_consumers: consumers.hard,
soft_consumers: consumers.soft,
files: [path.relative(repo, path.join(skill.dir, shimRel)), path.relative(repo, path.join(skill.dir, 'interface.json')), path.relative(repo, docPath), path.relative(repo, path.join(skill.dir, 'contract.lock.json'))],
blockers,
};
if (blockers.length) {
if (asJson) console.log(JSON.stringify({ ...plan, ok: false }, null, 2));
else {
console.log(`skill-retire — 拒绝退役 ${skill.name}(${blockers.length} 个门禁未过)`);
for (const blocker of blockers) {
console.log(` ${blocker.gate}: ${blocker.detail}`);
if (blocker.gate === 'hard-consumers') console.log(' 先在这些技能里改成调用替代技能,或让它们改由 pi-skill 提示走新入口');
else console.log(' 确认这些调用已经不需要了,再加 --force');
}
console.log(`字段: skill=${skill.name} ok=false blockers=${blockers.length}`);
}
console.log(`hint=先处理上面列出的项;硬依赖永远不能越过`);
process.exit(3);
}
if (!apply) {
if (asJson) console.log(JSON.stringify({ ...plan, ok: true, dry_run: true }, null, 2));
else {
console.log(`skill-retire — 预览(未落盘):${skill.name} → ${replacement.name}`);
console.log(` 调用量:${days} 天内 ${calls} 次;硬依赖:无${consumers.soft.length ? `;文档提及:${consumers.soft.join(', ')}` : ''}`);
for (const file of plan.files) console.log(` 将写:${file}`);
console.log(` 退役后行为:pi-skill ${skill.name} … → error=notfound + hint=pi-skill ${replacement.name} help`);
console.log(`字段: skill=${skill.name} to=${replacement.name} dry_run=true`);
console.log('hint=确认无误后加 --apply 落盘');
}
process.exit(0);
}
fs.mkdirSync(path.dirname(path.join(skill.dir, shimRel)), { recursive: true });
fs.writeFileSync(path.join(skill.dir, shimRel), shimSource, { mode: 0o755 });
fs.writeFileSync(path.join(skill.dir, 'interface.json'), JSON.stringify(nextDeclared, null, 2) + '\n');
if (docOld) fs.writeFileSync(docPath, docNew);
const reloaded = loadSkills(repo).find(candidate => candidate.name === skill.name);
const contract = writeContract(reloaded);
if (asJson) console.log(JSON.stringify({ ...plan, applied: true, ok: true, contract: contract.path }, null, 2));
else {
console.log(`skill-retire — 已退役 ${skill.name} → ${replacement.name}`);
console.log(` 写:${plan.files.join('、')}`);
console.log(` 验证:pi-skill ${skill.name} … → 现在会报 error=notfound 并指到 ${replacement.name}`);
if (skill.declared?.aliases?.length) console.log('提示:入口内容未变,无需重装 PATH shim;若别名有变才跑 skill-entry-install run');
console.log(`字段: skill=${skill.name} to=${replacement.name} applied=true`);
}
process.exit(0);

View File

@ -0,0 +1,98 @@
#!/usr/bin/env node
/**
* skill-state — 本机私有状态文件(`local/skills/<技能>/state.json`)的版本化原语(库,不是 CLI)。
*
* 为什么需要:状态文件随技能演进会换形状(字段改名、结构升级)。没有版本号时,
* 升级后的技能读到老文件只能猜——猜错就静默丢数据。约定:
* - 每条状态都带 `_schemaVersion`;当前版本由调用方声明;
* - 读到的版本比当前低 → 调 migrate 升上来,升级前先备份(<file>.v<旧版本>.bak.json);
* - 比当前高(用户装了更新的技能、又回退)→ 不猜、不写,返回 error='newer';
* - dryRun 只报告要做的事,不落盘(迁移前先给人看)。
*
* 用法(技能脚本里):
* import { loadState, saveState } from '../skill-interface/scripts/skill-state.mjs';
* const state = loadState(file, { version: 2, migrate: (data, from) => ({ data: { ...data, hits: data.count ?? 0 }, notes: [`count → hits`] }) });
* if (state.error) exitWithConfigHint(state);
* saveState(file, { ...state.data, hits: 3 });
*/
import fs from 'node:fs';
import path from 'node:path';
export const STATE_VERSION_KEY = '_schemaVersion';
const readJson = file => {
try { return { value: JSON.parse(fs.readFileSync(file, 'utf8')), error: null }; }
catch (error) {
if (error.code === 'ENOENT') return { value: undefined, error: null };
return { value: undefined, error: error.message };
}
};
/** 原子私有写:0600 + tmp + rename(避免半截文件)。 */
export function writeStateFile(file, value) {
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
const tmp = `${file}.tmp-${process.pid}`;
fs.writeFileSync(tmp, JSON.stringify(value, null, 2) + '\n', { mode: 0o600 });
fs.renameSync(tmp, file);
try { fs.chmodSync(file, 0o600); } catch { /* 某些文件系统不支持 */ }
return file;
}
/** 备份:<file>.v<旧版本>.bak.json(保留一份,覆盖旧的同版本备份)。 */
export function backupStateFile(file, fromVersion) {
if (!fs.existsSync(file)) return null;
const target = `${file}.v${fromVersion ?? 'none'}.bak.json`;
fs.copyFileSync(file, target);
return target;
}
/**
* 读状态并(必要时)迁移。返回:
* { data, version, from, migrated, notes, backup, file, existed, dryRun, error, newer }
* error: null | 'newer' | 'unreadable' | 'migrate-failed'
*/
export function loadState(file, { version = 1, migrate = null, dryRun = false, keepBackup = true } = {}) {
const base = { file, version, from: null, migrated: false, notes: [], backup: null, existed: false, dryRun, error: null, newer: false };
const { value, error } = readJson(file);
if (error) return { ...base, data: null, error: 'unreadable', message: error };
if (value === undefined) return { ...base, data: null }; // 还没有状态文件:调用方用默认值起手
if (value === null || typeof value !== 'object' || Array.isArray(value)) return { ...base, data: null, error: 'unreadable', message: '状态文件不是对象' };
const rawVersion = value[STATE_VERSION_KEY];
const from = Number.isInteger(rawVersion) ? rawVersion : 0;
if (from === version) return { ...base, data: value, from, existed: true };
if (from > version) return { ...base, data: value, from, existed: true, error: 'newer', newer: true, message: `状态文件版本 ${from} 比本技能支持的 ${version} 新(技能可能被回退过);不迁移、不覆盖` };
let next = { ...value };
const notes = [];
try {
if (migrate) {
const result = migrate(next, from) ?? {};
next = result.data ?? next;
notes.push(...(result.notes ?? []));
}
} catch (migrationError) {
return { ...base, data: value, from, existed: true, error: 'migrate-failed', message: migrationError.message };
}
next[STATE_VERSION_KEY] = version;
const plan = { ...base, data: next, from, existed: true, migrated: true, notes };
if (dryRun) return plan; // 只看计划:不动文件、不备份
const backup = keepBackup ? backupStateFile(file, from) : null;
writeStateFile(file, next);
return { ...plan, backup };
}
/** 写状态:自动带上当前版本号。 */
export function saveState(file, data, { version = 1 } = {}) {
return writeStateFile(file, { ...data, [STATE_VERSION_KEY]: version });
}
/** 给人/模型看的一行摘要(放进 check 命令或日志)。 */
export function describeState(result) {
if (!result.existed) return `状态文件还没有:${result.file}(首次跑主命令时创建)`;
if (result.error === 'newer') return `状态文件版本过新,未迁移:${result.message}`;
if (result.error) return `状态文件读不了(${result.error}):${result.message ?? ''}`;
if (!result.migrated) return `状态版本 ${result.version}(无需迁移):${result.file}`;
const what = result.notes.length ? result.notes.join(';') : '结构升级';
return `${result.dryRun ? '将迁移' : '已迁移'} ${result.from} → ${result.version}:${what}${result.backup ? `(备份 ${path.basename(result.backup)})` : ''}`;
}

View File

@ -0,0 +1,469 @@
#!/usr/bin/env node
/**
* skillcheck — 技能契约检查(只报告,不改动;可反复跑,用于提交前与同步后)。
*
* 用法:
* skillcheck [技能...] [--json] [--static] [--quiet]
* --static 跳过运行时探测(只做静态检查,速度快)
* --quiet 只打印有问题的项
*
* 检查项分四类:结构 / 文档 / 行为(安全探测)/ 入口一致性。全部可客观判定,不执行任何破坏性命令。
* 退出码:0 全部通过(可有 warn);1 有 error。
*/
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { execFileSync } from 'node:child_process';
import { loadSkills, repoRoot, shimDir, shimText, shimName, aliasOf, shimOnPath, runEntry, declaresCommand, ERROR_CODES, LIFECYCLE, RUNNERS } from './skill-registry.mjs';
import { dependencyGraph, consumersOf } from './skill-deps.mjs';
import { contractStatus } from './skill-contract.mjs';
import { binariesUsed, declaredButUnused, envKeysUsed, envKeyOf } from './skill-config.mjs';
const USAGE = `skillcheck — 技能契约检查
skillcheck [技能...] [--json] [--static] [--quiet] [--changed [<git ref>]]
--static 只做静态检查(不跑 help)
--quiet 只打印有问题的项
--changed 只检查「改动了的技能 + 依赖它的技能」(给出待跑清单,见 REFERENCE §13)
不带 ref 时对比工作区,带 ref(如 origin/main)时对比 git diff <ref>...HEAD
检查:interface.json 结构 / 平台标签 / 文档入口与相对路径 / 密钥字面量 /
help 可用性与 cwd 无关性 / 声明命令与 help 一致 / 延迟预算 / PATH 入口一致性 /
依赖声明与静态反查对账(deps-*,见 REFERENCE.md)。
修复建议直接写在每行的 fix= 里;退出码 1 表示有 error。`;
const args = process.argv.slice(2);
// 检查器自己发起的探测(help 探测、未知命令探测)不是真实调用,不进 pi-skill 的调用留痕。
process.env.PI_SKILL_LOG = '0';
if (args.includes('help') || args.includes('-h') || args.includes('--help')) { console.log(USAGE); process.exit(0); }
const unknown = args.filter((value, index) => value.startsWith('-') && !['--json', '--static', '--quiet'].includes(value) && !(value === '--changed' || args[index - 1] === '--changed'));
if (unknown.length) { console.error(`error=usage 未知参数 ${unknown.join(' ')}\nhint=skillcheck help`); process.exit(2); }
const asJson = args.includes('--json');
const skipRuntime = args.includes('--static');
const quiet = args.includes('--quiet');
const changedIndex = args.indexOf('--changed');
const changedRef = changedIndex === -1 ? null : (args[changedIndex + 1] && !args[changedIndex + 1].startsWith('-') ? args[changedIndex + 1] : 'HEAD');
let filter = args.filter(value => !value.startsWith('-') && !(changedIndex !== -1 && value === args[changedIndex + 1]));
const LATENCY_BUDGET_MS = process.platform === 'win32' ? 400 : 250;
// 选路表目标 <=60 行;含语义/失败表的运维型技能硬上限 150 行(超过必须移 REFERENCE.md)
/** demo 声明(技能自报的典型功能演示)的形状校验:返回问题描述或 ''。 */
const DEMO_KINDS = ['cmd', 'type', 'key', 'say', 'pause'];
const DEMO_KEYS = new Set([
'enter', 'ctrl-enter', 'esc', 'tab', 'space', 'backspace', 'delete', 'home', 'end',
'pageup', 'pagedown', 'backtab', 'up', 'down', 'left', 'right',
...'abcdefghijklmnopqrstuvwxyz'.split('').map(char => `ctrl-${char}`),
...Array.from({ length: 12 }, (_, index) => `F${index + 1}`),
]);
function demoProblem(demo) {
if (!demo || typeof demo !== 'object' || Array.isArray(demo)) return 'demo 必须是对象';
if (!Array.isArray(demo.steps) || !demo.steps.length) return 'demo.steps 是非空数组';
for (const [index, step] of demo.steps.entries()) {
if (!step || typeof step !== 'object') return `steps[${index}] 不是对象`;
const kinds = DEMO_KINDS.filter(kind => step[kind] !== undefined);
if (kinds.length !== 1) return `steps[${index}] 只能有 ${DEMO_KINDS.join('/')} 中的一个,收到 ${kinds.join(',') || '无'}`;
if (kinds[0] === 'key' && !DEMO_KEYS.has(step.key)) return `steps[${index}].key 不认识:${step.key}`;
for (const kind of ['cmd', 'type', 'say']) {
if (step[kind] !== undefined && (typeof step[kind] !== 'string' || !step[kind].trim())) return `steps[${index}].${kind} 必须是非空字符串`;
}
if (step.expect !== undefined && (typeof step.expect !== 'string' || !step.expect)) return `steps[${index}].expect 必须是非空字符串`;
}
return '';
}
const DOC_LINE_BUDGET = 150;
const SECRET_PATTERNS = [/\b(sk-[A-Za-z0-9]{16,})/, /\b(ghp_|gho_|github_pat_)[A-Za-z0-9_]{16,}/, /\bAKIA[0-9A-Z]{12,}/, /-----BEGIN [A-Z ]*PRIVATE KEY-----/, /\b(password|passwd|secret|token|api[_-]?key)\s*[:=]\s*["'][^"'{}$][^"']{7,}["']/i];
const repo = repoRoot();
const findings = [];
const report = (level, skill, check, message, fix) => findings.push({ level, skill, check, message, fix });
const loaded = loadSkills(repo);
// P6.3 增量验证:改动技能 + 依赖它的技能(谁会被我的改动弄坏)
let changedSummary = null;
if (changedRef) {
const git = (commandArgs) => {
try { return execFileSync('git', ['-C', repo, ...commandArgs], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }); }
catch (error) { console.error(`error=external git ${commandArgs.join(' ')} 失败:${(error.stderr || error.message || '').trim().split('\n')[0]}\nhint=确认这是 git 仓库且 ref 存在(skillcheck --changed origin/main)`); process.exit(2); }
};
const listed = changedRef === 'HEAD'
? git(['status', '--porcelain', '--untracked-files=all']).split('\n').map(line => line.slice(3).trim()).filter(Boolean)
: git(['diff', '--name-only', `${changedRef}...HEAD`]).split('\n').filter(Boolean);
const changedSkills = new Set();
for (const file of listed) {
const match = /^skills\/([^/]+)\//.exec(file);
if (match) changedSkills.add(match[1]);
}
const graphAll = dependencyGraph(loaded);
const affected = new Set(changedSkills); // 会被改坏 / 需要一起验证
const mentioned = new Set(); // 只在文档里提到(不用跑测试,但改接口时值得看一眼)
for (const name of changedSkills) {
const consumers = consumersOf(graphAll, name);
for (const consumer of consumers.hard) affected.add(consumer);
for (const consumer of consumers.soft) if (!affected.has(consumer)) mentioned.add(consumer);
}
const targets = [...affected].filter(name => loaded.some(skill => skill.name === name)).sort();
const mentionedNames = [...mentioned].filter(name => loaded.some(skill => skill.name === name)).sort();
const tests = targets
.filter(name => fs.existsSync(path.join(repo, 'skills', name, 'tests')))
.map(name => `node --test "skills/${name}/tests/*.test.mjs"`);
const selfTests = 'node --test "skills/skill-interface/tests/*.test.mjs"'; // 接口改动一律跑契约全套
if (!tests.includes(selfTests)) tests.push(selfTests);
changedSummary = { changed: [...changedSkills], affected: targets, mentioned: mentionedNames, tests, files: listed.length };
if (!asJson) {
console.log(listed.length ? `改动文件 ${listed.length} 个,影响技能 ${targets.length} 个` : '工作区没有改动');
console.log(`待查: ${targets.length ? targets.join(' ') : '(无)'}${mentionedNames.length ? `(另有文档提及:${mentionedNames.join(' ')})` : ''}`);
console.log(`待跑: ${targets.length ? tests.join(' && ') : '(无)'}`);
}
if (!targets.length) { console.log('字段: changed=0 affected=0'); process.exit(0); } // 没有受影响技能 → 没有可查的
filter = targets;
}
/** 技能 scripts/ 下全部源码文本(用于配置键名、分级条款等静态检查)。 */
function scriptsText(skill) {
const dir = path.join(skill.dir, 'scripts');
if (!fs.existsSync(dir)) return '';
const chunks = [];
const walk = current => {
for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
const full = path.join(current, entry.name);
if (entry.isDirectory()) { if (entry.name !== 'node_modules' && !entry.name.startsWith('.')) walk(full); continue; }
if (!/\.(mjs|js|cjs|ts|py|sh|zsh|ps1|cmd|bat)$/i.test(entry.name)) continue;
try { chunks.push(fs.readFileSync(full, 'utf8')); } catch { /* 读不了就不算数 */ }
}
};
walk(dir);
return chunks.join('\n');
}
const skills = loaded.filter(skill => !filter.length || filter.includes(skill.name));
if (!skills.length) { console.error(`error=usage 没有匹配的技能:${filter.join(' ')}\nhint=pi-skill list`); process.exit(2); }
// 依赖图基于全库构建(解析、别名归一与环检测都需要全量),只在报告时按 filter 收敛。
const graph = dependencyGraph(loaded);
for (const skill of skills) {
const declared = skill.declared ?? {};
const doc = skill.markdown;
for (const error of skill.errors) report('error', skill.name, error.check, error.message, error.fix);
// 结构:平台标签(缺了 Pi 不会加载,属于硬错误)
if (!skill.platforms.length) report('error', skill.name, 'platforms', 'SKILL.md frontmatter 缺少合法 platforms', '写 platforms: [all] 或 [darwin] 等');
// 契约基线:接口面(commands/errors/tier/platforms/别名)与 contract.lock.json 对比
const contract = contractStatus(skill);
if (contract.state === 'missing') {
report('warn', skill.name, 'contract-missing', '没有 contract.lock.json 契约基线', `skill-contract update ${skill.name} --apply`);
} else if (contract.state === 'drift') {
const breaking = contract.changes.filter(change => change.level === 'breaking').map(change => change.detail).join(';');
// 被依赖的接口不得无 shim 删除:给出依赖方名单,让“兼容处理”有具体对象
const { hard } = consumersOf(graph, skill.name);
const removed = contract.changes.some(change => change.level === 'breaking' && /命令被删除|别名移除/.test(change.detail));
const audience = removed && hard.length ? `(硬依赖方:${hard.join('、')})` : '';
report('error', skill.name, 'contract-drift', `接口面破坏性变化:${breaking}${audience}`, '先做兼容:旧名留 shim(仍可调,返回 error=usage hint=指向新名)或改完依赖方,再 skill-contract update 并写 CHANGELOG');
} else if (contract.state === 'additive') {
const additive = contract.changes.filter(change => change.level === 'additive').map(change => change.detail).join(';');
report('warn', skill.name, 'contract-additive', `接口面新增(向后兼容):${additive}`, `确认后 skill-contract update ${skill.name} --apply`);
}
// 入口守护:主入口判断必须 realpath 安全(符号链接路径会让技能静默不执行、退出码 0)
const walkScripts = dir => fs.readdirSync(dir, { withFileTypes: true }).flatMap(entry => {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) return walkScripts(full);
return /\.(mjs|cjs|js)$/.test(entry.name) ? [full] : [];
});
const scriptsDirForGuard = path.join(skill.dir, 'scripts');
if (fs.existsSync(scriptsDirForGuard)) {
for (const file of walkScripts(scriptsDirForGuard)) {
const lines = fs.readFileSync(file, 'utf8').split('\n');
const index = lines.findIndex(text => /process\.argv\[1\]/.test(text) && /import\.meta\.url/.test(text) && /[!=]==?/.test(text) && !/realpath/.test(text));
if (index >= 0) report('error', skill.name, 'entry-main-guard', `${path.relative(skill.dir, file)}:${index + 1} 用字符串比较判断「是不是主入口」`, '改成 fs.realpathSync 两侧再比(macOS 的 /tmp、/var 是符号链接,直接比字符串会让入口静默不执行、退出码 0)');
}
}
// 结构:admin 声明(模型可见入口唯一,运维脚本必须登记在案)
const scriptsDir = path.join(skill.dir, 'scripts');
if (fs.existsSync(scriptsDir)) {
const registered = new Set([path.basename(declared.entry?.path ?? ''), ...(declared.admin ?? []).map(item => path.basename(item?.path ?? ''))]);
const extras = fs.readdirSync(scriptsDir)
.filter(name => !registered.has(name) && !name.startsWith('_') && !name.includes('.test.') && !name.startsWith('test_'))
.filter(name => { try { return !fs.statSync(path.join(scriptsDir, name)).isDirectory(); } catch { return false; } })
.filter(name => (RUNNERS[path.extname(name)] || (() => { try { return (fs.statSync(path.join(scriptsDir, name)).mode & 0o111) !== 0; } catch { return false; } })()))
.filter(name => /help/i.test(fs.readFileSync(path.join(scriptsDir, name), 'utf8')))
// 导出符号的文件是库(被 import),不是运维入口:只有不导出的才算「未登记脚本」。
.filter(name => !/^\s*export\s+(async\s+)?(function|const|let|var|class|default)/m.test(fs.readFileSync(path.join(scriptsDir, name), 'utf8')))
if (extras.length) report('warn', skill.name, 'admin-undeclared', `scripts/ 下有 ${extras.length} 个未登记的脚本:${extras.join(', ')}`, '登记到 interface.json 的 admin[],或放进技能内子目录当库文件');
}
// 发布声明:形状、命名约定(公开 CLI 仓统一 <工具>-cli)、版本文件
if (declared.publish !== undefined) {
const publish = declared.publish;
const named = value => typeof value === 'string' && value.trim();
const shapeOk = publish && typeof publish === 'object' && !Array.isArray(publish)
&& (named(publish.repo) || named(publish.package_repo))
&& (publish.mirrors === undefined || (Array.isArray(publish.mirrors) && publish.mirrors.every(item => typeof item === 'string' && item.trim())));
if (!shapeOk) {
report('error', skill.name, 'publish-shape', 'interface.json 的 publish 声明形状不对', '写成 { "repo": "<可推送的仓 URL>", "mirrors"?: ["…"] }');
} else {
const cliRepo = typeof publish.repo === 'string' ? publish.repo.trim() : '';
const pkgRepo = typeof publish.package_repo === 'string' ? publish.package_repo.trim() : '';
const base = path.basename((cliRepo || pkgRepo).replace(/\.git$/, ''));
if (cliRepo && !path.basename(cliRepo.replace(/\.git$/, '')).endsWith('-cli')) {
report('warn', skill.name, 'publish-naming', `CLI 仓名 ${path.basename(cliRepo.replace(/\.git$/, ''))} 不以 -cli 结尾`, '公开 CLI 工具仓统一 <工具>-cli;装出来的命令仍是 <工具>(不带后缀)');
}
if (pkgRepo && path.basename(pkgRepo.replace(/\.git$/, '')) !== skill.name) {
report('warn', skill.name, 'publish-naming', `包仓名 ${path.basename(pkgRepo.replace(/\.git$/, ''))} 与技能名不一致`, `包仓统一用技能名 <工具>(这里是 ${skill.name})`);
}
if (!fs.existsSync(path.join(skill.dir, 'VERSION'))) report('error', skill.name, 'publish-version', '声明了 publish 却没有 VERSION 文件', '加 VERSION(语义化版本,如 0.1.0);发布时用它打 v<版本> tag,已发布的版本不改写');
}
}
// 文档:入口统一 + 不出现裸相对路径
if (doc) {
const lines = doc.split('\n');
const lineCount = lines.length - (lines.at(-1) === '' ? 1 : 0); // 与 wc -l 一致
if (lineCount > DOC_LINE_BUDGET) report('warn', skill.name, 'doc-length', `SKILL.md ${lineCount} 行,超过 ${DOC_LINE_BUDGET} 行预算`, '把细节移到 REFERENCE.md,文档只留选路与失败表');
if (!doc.includes('pi-skill')) report('error', skill.name, 'doc-entry', 'SKILL.md 未指向统一切入点', `在文档里写 pi-skill ${skill.name} help`);
else if (skill.name !== 'skill-interface' && !doc.includes(`pi-skill ${skill.name}`)) report('warn', skill.name, 'doc-entry', 'SKILL.md 里没有直接给出 pi-skill <技能> 的调用形式', `补一行 pi-skill ${skill.name} help`);
// 前 60 行必须自成选路表:弱模型只看开头就要能选路与恢复
const head = lines.slice(0, 60).join('\n');
if (!/pi-skill/.test(head)) report('warn', skill.name, 'doc-scannable-top', '前 60 行里没有入口命令', '把「入口」段提到文档前 60 行内');
if (!/(失败|错误|排障|error=)/.test(head)) report('warn', skill.name, 'doc-scannable-top', '前 60 行里没有失败→动作信息', '把失败表/排障入口提到前 60 行');
const relative = lines.map((line, index) => [index + 1, line]).filter(([, line]) => /^\s*(?:\$ )?(?:bash|sh|zsh|node|python3?)\s+scripts\//.test(line));
for (const [line, text] of relative) report('error', skill.name, 'doc-relative-path', `SKILL.md:${line} 用裸相对路径调用(模型 cwd 不是技能目录)`, `改成 pi-skill ${skill.name} …;原文:${text.trim().slice(0, 60)}`);
for (const pattern of SECRET_PATTERNS) {
const hit = doc.match(pattern);
if (hit) report('error', skill.name, 'secret-literal', `SKILL.md 出现疑似密钥字面量:${hit[0].slice(0, 24)}…`, '移除凭据,只写环境变量名');
}
}
// 结构:破坏性命令必须有干跑能力(显式 --dry-run,或「预览优先 + 显式确认旗标」)
const destructive = (declared.commands ?? []).filter(command => command.destructive === true);
if (destructive.length && declared.dryRun !== true && declared.dryRun !== 'external') {
report('error', skill.name, 'contract-dryrun', `含破坏性命令(${destructive.map(command => command.name).join(',')})但未声明 dryRun`, '接口里加 "dryRun": true(外部工具无法干跑时用 "external" 并在文档里写清风险)');
}
if (declared.dryRun === 'external' && destructive.length) {
report('warn', skill.name, 'contract-dryrun', '破坏性命令依赖外部工具,无干跑能力(已声明 external)', '文档里写清不可逆风险与确认要求');
}
if (declared.dryRun === true && skill.entry?.file) {
const source = fs.readFileSync(skill.entry.file, 'utf8');
const hasPreview = /dry[-_]?run/i.test(source);
const hasConfirm = /(--yes|--force|--apply)/.test(source);
if (!hasPreview && !hasConfirm) report('error', skill.name, 'contract-dryrun', 'interface.json 声明 dryRun,但入口源码里既无 --dry-run 也无 --apply/--yes(没有预览路径)', '实现 --dry-run,或改成默认预览 + --apply');
if (!hasPreview) report('warn', skill.name, 'contract-confirm', '只找到 --apply/--yes(默认直接执行),无法先干跑看计划', '补 --dry-run 让模型能先展示计划');
}
if (declared.tier === 2 || (declared.tier === 3 && declared.lifecycle !== undefined)) {
for (const item of LIFECYCLE) if (!(declared.lifecycle ?? []).includes(item)) report('error', skill.name, 'contract-lifecycle', `T${declared.tier} 缺生命周期命令 ${item}`, '补 status/start/stop/logs 四件套');
}
// T2 liveness 证据(当前 warn 级):四件套只证明「有 status」,这一条证明「status 答得出上次真的动过是什么时候」。
// 主语是「持续承诺」而不是接口形状:守护报心跳、管理器报上次探测时间、任务器报任务最近动作,同一句话对三类主体都成立
// (sysflood 旧版四件套齐全,status 只会说 running pid=…,于是悄声烧了 17 天 CPU 没人发现)
if (declared.tier === 2 || (declared.tier === 3 && declared.lifecycle !== undefined)) {
const proof = declared.liveness;
if (typeof proof !== 'string' || !proof.trim()) {
report('warn', skill.name, 'contract-liveness', `T${declared.tier} 没声明 liveness 证据字段:status 说不出“上次真的动过是什么时候”`, 'interface.json 加 "liveness": "<status 输出里的时间/动作字段名>"(守护如 last_sample,管理外部对象的如 as_of,管任务的如 last_write),并让 status 真的打印它');
} else if (skill.entry?.file && !fs.readFileSync(skill.entry.file, 'utf8').includes(proof)) {
report('warn', skill.name, 'contract-liveness', `声明了 liveness=${proof},但入口源码里找不到这个字段(status 大概不会输出它)`, `让 status 打印 ${proof}=<时间/动作>,或把 liveness 改成真实字段名`);
}
}
// T3(长时/远端/批处理):续跑 + 单写入者锁 + 超时/重试;外部命令入口无法静态检查,改为提醒
if (declared.tier === 3) {
if (skill.entry?.kind === 'command') {
report('warn', skill.name, 'contract-tier3', 'T3 但入口是外部命令,无法静态校验续跑/锁/超时', '在 SKILL.md 里写清续跑与并发的实际行为');
} else if (skill.entry?.file) {
const source = fs.readFileSync(skill.entry.file, 'utf8');
const clauses = [
['续跑/断点续传', /(resume|continue-at|continue_at|\u7eed\u8dd1|\u65ad\u70b9\u7eed\u4f20|--plan)/i],
['单写入者锁', /(flock|fcntl|lockfile|lock[_ -]?file|\.lock)/i],
['超时或重试', /(timeout|--retry|attempts|retry)/i],
];
for (const [label, pattern] of clauses) {
if (!pattern.test(source)) report('error', skill.name, 'contract-tier3', `T3 缺「${label}」能力`, '实现它,或把 tier 降为 1/2(T3 用于长时/远端/批处理)');
}
}
}
if (declared.errors?.length) {
const unknownCodes = declared.errors.filter(code => !ERROR_CODES.includes(code));
for (const code of unknownCodes) report('error', skill.name, 'contract-errors', `未定义错误码 ${code}`, `只用 ${ERROR_CODES.join('/')}`);
}
// 演示声明(可选):技能自报「我该怎么被演示」,给 demo-publish survey 用。
// 有就按形状校验;没有不罚(但 survey 只能退回只读命令清单,会提醒作者补)。
if (declared.demo !== undefined) {
const problem = demoProblem(declared.demo);
if (problem) {
report('error', skill.name, 'contract-demo', `interface.json 的 demo 声明有问题:${problem}`,
'demo 形如 {"title":"…","summary":"…","steps":[{"say":"① …"},{"cmd":"…","expect":"…"}]};每步只能有 cmd/type/key/say/pause 中的一个,key 用 enter/ctrl-enter/ctrl-c 这类名字');
}
}
// 依赖:声明(契约)与静态反查(事实)对账。声明错误是 error,事实与声明不一致先报 warn(ROADMAP D9 渐进收紧)。
const node = graph.nodes.get(skill.name);
const declaredDeps = declared.dependsOn ?? [];
const targetOf = dep => graph.index.get(dep.skill) ?? dep.skill;
if (node) {
for (const dep of declaredDeps) {
const target = targetOf(dep);
const targetSkill = loaded.find(candidate => candidate.name === target);
if (!targetSkill) { report('error', skill.name, 'deps-resolve', `dependsOn 引用了不存在的技能 ${dep.skill}`, '改技能名,或删掉该依赖'); continue; }
for (const command of dep.commands ?? []) {
if (!declaresCommand(targetSkill, command)) report('error', skill.name, 'deps-commands', `依赖 ${target}.${command},但该技能没有声明这个命令`, `改命令名,或先在 ${target} 声明 ${command}`);
}
const targetTier = targetSkill.declared?.tier;
if (dep.tierAtLeast !== undefined && (targetTier ?? 0) < dep.tierAtLeast) report('error', skill.name, 'deps-tier', `依赖 ${target} 要求 T${dep.tierAtLeast},实际是 T${targetTier ?? '?'}`, '降低要求,或先补全被依赖技能');
const targetPlatforms = targetSkill.platforms ?? [];
if (targetPlatforms.length && !targetPlatforms.includes('all')) {
const mine = skill.platforms ?? [];
const overlap = !mine.length || mine.includes('all') ? [] : mine.filter(value => targetPlatforms.includes(value));
if (!overlap.length) report('warn', skill.name, 'deps-platform', `依赖 ${target} 只在 ${targetPlatforms.join(',')} 可用,本技能声明 ${mine.join(',') || '(无)'}`, '收窄 platforms,或在 SKILL.md 写明降级路径(确认不是缺陷后可忽略)');
}
if (!node.hard.has(target)) report('warn', skill.name, 'deps-unused', `声明了依赖 ${target},但脚本里没有找到实际调用`, '删掉声明,或补上真实调用(只在 SKILL.md 提到不算硬依赖)');
}
for (const target of node.hard.keys()) {
if (!declaredDeps.some(dep => targetOf(dep) === target)) {
// D9 迁移期结束(D18):脚本里真会调用的目标必须声明,否则破坏性变更会静默发生。
report('error', skill.name, 'deps-undeclared', `脚本里调用了 ${target},但 dependsOn 未声明`, `在 interface.json 加 { "skill": "${target}" }(按真实用到的命令补 commands)`);
}
}
}
// 配置声明:声明烂掉用反向检查(声明了但代码里从没出现 → warn);环境变量键名是字面量,可以正向对账。
// (正向扫描 `config.<key>` 代码模式正则太脆,见 ROADMAP D14)
if ((declared.config ?? []).length) {
const text = scriptsText(skill);
for (const item of declaredButUnused(skill, text)) {
report('warn', skill.name, 'config-declared-unused', `config 声明了 ${item.key},但 scripts/ 里从没出现这个名字`, '删掉声明,或把代码/文档里的键名对齐(声明是 doctor 与 setup 引导的真源)');
}
const normalize = key => key.replace(/([a-z0-9])([A-Z])/g, '$1_$2').toLowerCase().replace(/-/g, '_');
const declaredKeys = new Set((declared.config ?? []).map(item => normalize(item.key)));
for (const key of envKeysUsed(skill, text)) {
if (!declaredKeys.has(normalize(key))) report('warn', skill.name, 'config-env-undeclared', `读了环境变量 ${envKeyOf(skill.name, key)},但 config 没声明 ${key}`, `在 interface.json 的 config[] 里声明 {"key": "${key}"}`);
}
}
// 首次使用引导(可分发契约):别人拿到技能第一眼就知道要配什么/装什么。
const needsSetup = (declared.config ?? []).some(item => item.required === true) || (declared.requires ?? []).length > 0;
if (needsSetup) {
const head = (skill.markdown ?? '').split('\n').slice(0, 60).join('\n');
const hasCheck = (declared.commands ?? []).some(command => command.name === 'check' || command.name === 'status'); // check 或 status 都算「能自查」
if (!/\bcheck\b|自检|体检|首次|先跑|前置/.test(head)) {
report('warn', skill.name, 'doc-first-use', '有必需配置/外部依赖,但 SKILL.md 前 60 行没写怎么提前检查(check/首次使用指引)', '在 SKILL.md 开头加一行「首次使用先跑 pi-skill <技能> check」');
}
if (!hasCheck && (declared.config ?? []).length > 0) {
report('warn', skill.name, 'setup-command', '声明了 config 但没有 check 命令,缺配置时只能等主命令失败才知道', '加一个只读 check:报缺口 + 给 pi-skill <技能> setup 一行命令');
}
}
// 外部依赖声明(requires):脚本里真调用的外部程序必须声明,否则新机器上装什么靠猜。
{
const others = loaded.filter(other => other.name !== skill.name); // 别名归一反查要用全库,不能只看本次 filter
for (const binary of binariesUsed(skill, scriptsText(skill), others)) {
report('warn', skill.name, 'requires-undeclared', `脚本里调用了外部程序 ${binary},但 requires[] 未声明`, `在 interface.json 加 { "kind": "binary", "name": "${binary}", "install": "…", "check": "pi-skill ${skill.name} check" }`);
}
}
// 外部系统(E1/E2):读了别人的数据,就得说清「从哪来、什么时候的」;有凭据就得能报登录态
{
const configs = declared.config ?? [];
const externalSystem = configs.some(item => item.type === 'url' || item.secret === true)
|| (declared.requires ?? []).some(item => item.kind === 'service' || item.kind === 'account');
const text = `${scriptsText(skill)}\n${doc ?? ''}`; // 字段可能只在 help 文案里(输出用 ${k}=${v} 拼)
const readCommands = (declared.commands ?? []).filter(command => !command.destructive).length;
if (externalSystem && readCommands) {
const missing = [['source', /\bsource\b\s*[:=]/], ['as_of', /\bas_of\b\s*[:=]/]]
.filter(([, pattern]) => !pattern.test(text)).map(([field]) => field);
if (missing.length) report('warn', skill.name, 'external-source', `读取类输出缺 ${missing.join(' / ')}(外部系统的数据要能说清来源与时刻)`, '在读取命令的输出里加 source=<接口/页面> as_of=<ISO 时刻>;缓存只作补充,不能单独当答案');
}
if (configs.some(item => item.secret === true) && !text.includes('登录态') && !/登录|credential|signin|login/i.test(text)) {
report('warn', skill.name, 'login-coverage', '有凭据类配置,但脚本里看不到登录态检查/续登的痕迹', '让 check 命令报登录态(有效/失效/续登结果),别等主命令才发现掉登录');
}
}
// 生命周期:退役技能必须留指路牌,且不能还有人硬依赖(P6.2)
if (declared.status === 'deprecated') {
const stillHard = consumersOf(graph, skill.name).hard;
if (stillHard.length) report('error', skill.name, 'deprecated-consumers', `已标 deprecated,但这些技能还在脚本里硬依赖它:${stillHard.join(', ')}`, '先改调用方;退役用 skill-retire(会保留指路牌)而不是直接删');
if (doc && !/已退役|deprecated/i.test(doc.split('\n').slice(0, 40).join('\n'))) report('warn', skill.name, 'deprecated-note', 'SKILL.md 前 40 行没写「已退役/替代技能」', '在文档顶部写清替代入口,否则模型还会照老文档调用');
}
// 行为:安全探测(只跑 help)
// 本平台不加载的技能(platforms 不含当前平台)只做静态检查:Pi 的加载器本来就跳过它们,
// 在这里跑 help 只会因为缺 macOS/Linux 依赖而误报。结构/文档/入口一致性照旧检查。
const platformEnabled = skill.platforms.includes('all') || skill.platforms.includes(process.platform);
if (!skipRuntime && skill.entry && !skill.errors.length && !platformEnabled) {
report('warn', skill.name, 'runtime-skipped', `platforms=${skill.platforms.join(',')} 不含 ${process.platform},跳过 help 运行时探测`, '在目标平台上跑 skillcheck 才能验证 help');
}
if (!skipRuntime && platformEnabled && skill.entry && !skill.errors.length) {
const fromRepo = runEntry(skill.entry, ['help'], { cwd: repo, timeoutMs: 20_000 });
const firstLine = (fromRepo.stdout.trim().split('\n')[0] || fromRepo.stderr.trim().split('\n')[0] || '').trim();
if (fromRepo.status !== 0 || !firstLine) {
report('error', skill.name, 'help-works', `pi-skill ${skill.name} help 退出码 ${fromRepo.status ?? 'none'}${fromRepo.timeout ? '(超时)' : ''}:${firstLine.slice(0, 80) || '无输出'}`, '入口必须支持 help 且退出码 0');
} else if (/未知参数|unknown (command|argument)|invalid choice|用法:|^Usage:/i.test(firstLine) && !/^[^:]{0,40}—/.test(firstLine)) {
if (/未知参数|unknown (command|argument)|invalid choice/i.test(firstLine)) report('error', skill.name, 'help-works', `help 未被识别:${firstLine.slice(0, 80)}`, '把 help 加进命令分派(与 --help 等价)');
}
if (fromRepo.ms > LATENCY_BUDGET_MS) report('warn', skill.name, 'help-latency', `help 冷启动 ${fromRepo.ms}ms 超过预算 ${LATENCY_BUDGET_MS}ms`, '检查入口是否在启动时做了磁盘/网络工作');
if (declared.helpListsCommands !== false) {
const missing = (declared.commands ?? []).map(command => command.name).filter(name => !new RegExp(`(^|[^a-z0-9-])${name}([^a-z0-9-]|$)`).test(`${fromRepo.stdout}\n${fromRepo.stderr}`));
if (missing.length) report('error', skill.name, 'help-commands', `help 输出里找不到声明的命令:${missing.join(',')}`, '改 interface.json 或补 help(命令名必须与 help 一致)');
}
const fromTmp = runEntry(skill.entry, ['help'], { cwd: fs.mkdtempSync(path.join(os.tmpdir(), 'skillcheck-')), timeoutMs: 20_000 });
if (fromTmp.status !== 0) report('error', skill.name, 'help-cwd-independent', `在无关 cwd 下 help 失败(退出码 ${fromTmp.status ?? 'none'}):${(fromTmp.stderr.trim().split('\n')[0] || '').slice(0, 80)}`, '入口不得依赖工作目录,用自身路径定位资源');
if (declared.probeUnknownCommand === true) {
const probe = runEntry(skill.entry, ['__skillcheck_probe__'], { cwd: repo, timeoutMs: 20_000 });
if (probe.status === 0) report('error', skill.name, 'usage-exit-code', '未知命令返回 0(应报错并给用法)', '未知命令时退出码置 2 并打印 usage');
}
}
// 入口一致性:PATH shim 必须与 interface.json 一致(含运维入口)
const dir = shimDir();
const declaredAliases = [
...(declared.aliases ?? []).map(alias => ({ alias, argv: skill.entry?.argv })),
...skill.admin.map(item => ({ alias: item.alias, argv: item.argv })),
];
for (const { alias, argv } of declaredAliases) {
if (!argv) continue;
const file = path.join(dir, shimName(alias));
if (!fs.existsSync(file)) { report('error', skill.name, 'alias-missing', `PATH 入口 ${alias} 缺失`, 'bash skills/skill-interface/scripts/install.sh run'); continue; }
const expected = shimText({ argv });
if (fs.readFileSync(file, 'utf8') !== expected) report('error', skill.name, 'alias-drift', `PATH 入口 ${alias} 与 interface.json 不一致`, 'bash skills/skill-interface/scripts/install.sh run');
if (process.platform !== 'win32' && !(fs.statSync(file).mode & 0o111)) report('error', skill.name, 'alias-permission', `PATH 入口 ${alias} 没有执行位`, 'skill-entry-install run 会重写为 0755(Windows 由 .cmd 扩展名决定可执行,不检查位)');
}
}
// 全仓检查:依赖环(hard ∪ declared 边;只报告与本次筛选相关的环)
const reported = new Set(skills.map(skill => skill.name));
for (const cycle of graph.cycles) {
const members = cycle.filter(name => reported.has(name));
if (!members.length) continue;
report('error', members[0], 'deps-cycle', `依赖环:${cycle.join(' → ')} → ${cycle[0]}`, '打破环:任一方去掉对对方的依赖,或把公共能力提取为第三个技能');
}
// 全仓检查:孤儿入口(指向本仓库、但没有任何技能声明的 PATH 文件)
if (!filter.length) {
const dir = shimDir();
const declaredAliases = new Set(loaded.flatMap(skill => [...(skill.declared?.aliases ?? []), ...skill.admin.map(item => item.alias)]));
for (const name of fs.existsSync(dir) ? fs.readdirSync(dir) : []) {
if (declaredAliases.has(aliasOf(name))) continue;
const file = path.join(dir, name);
let owned = false;
try { owned = fs.lstatSync(file).isSymbolicLink() ? fs.realpathSync(file).startsWith(fs.realpathSync(repo)) : fs.readFileSync(file, 'utf8').includes('由 skill-interface 生成'); } catch { owned = false; }
if (owned) report('warn', '~/.local/bin', 'alias-orphan', `${name} 指向本仓库但未被任何 interface.json 声明`, '在对应技能里声明 alias,或删除该入口');
}
if (!shimOnPath(dir)) report('warn', '~/.local/bin', 'alias-path', `${dir} 不在 PATH 上`, '把该目录加入 PATH');
}
const errors = findings.filter(finding => finding.level === 'error');
const warnings = findings.filter(finding => finding.level === 'warn');
if (asJson) {
console.log(JSON.stringify({ ok: errors.length === 0, skills: skills.length, ...(changedSummary ? { changed_scope: changedSummary } : {}), errors, warnings }, null, 2));
} else {
const shown = quiet ? findings.filter(finding => finding.level === 'error') : findings;
for (const finding of shown) {
console.log(`${finding.level === 'error' ? 'ERROR' : 'warn '} ${finding.skill.padEnd(24)} ${finding.check.padEnd(22)} ${finding.message}`);
if (finding.fix) console.log(` fix: ${finding.fix}`);
}
console.log(`\n结论: ${skills.length} 个技能,${errors.length} 个 error,${warnings.length} 个 warn${skipRuntime ? '(未跑运行时探测)' : ''}`);
console.log(`字段: errors=${errors.length} warnings=${warnings.length} skills=${skills.length}`);
}
process.exit(errors.length ? 1 : 0);