commit 3094f5b1dc2571acede362fa4e61e9ffdf04c13d Author: Kinneyzhang Date: Fri Oct 2 09:26:45 2026 +0800 release v0.1.0 diff --git a/MANIFEST.json b/MANIFEST.json new file mode 100644 index 0000000..e3c2434 --- /dev/null +++ b/MANIFEST.json @@ -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": [] + } +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..12c808f --- /dev/null +++ b/README.md @@ -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` 会告诉还缺什么。 diff --git a/package.json b/package.json new file mode 100644 index 0000000..806d12f --- /dev/null +++ b/package.json @@ -0,0 +1,5 @@ +{ + "name": "skill-interface", + "version": "0.1.0", + "description": "所有技能的统一切入点:技能目录、用法转发、PATH 入口注册与契约检查" +} diff --git a/skills/skill-interface/REFERENCE.md b/skills/skill-interface/REFERENCE.md new file mode 100644 index 0000000..18289bd --- /dev/null +++ b/skills/skill-interface/REFERENCE.md @@ -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=` | +| 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 到 `/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=`(仅非 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 包)里配置命令路径。 + +**分发出去的定义**:对方把目录放进 `/skills/` 后,`pi-skill list` 能看到、`pi-skill <技能> check` 能告诉他还缺什么、`skillcheck <技能>` 无 error(tests/ 不随包,clean-room 用包内入口自检;见 `tests/pack.test.mjs` 的 clean-room 用例)。 + +**两条出口怎么选**(同一个源;按接收方选一条或都要): + +| 想让谁用 | 出口 | 操作 | 接收方拿到后 | +|---|---|---|---| +| Pi 用户(装进 Pi 用) | `skill-pack` | `skill-pack <技能>` → `/<技能>-<版本>/` + `.tar.gz` | 解压放进 `/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://<工具>@v<版本>`(或 npm 发布后 `pi install npm:<包>`) | + +- Pi 没有自己的私有 registry:官方安装走 **npm 或 git**;`badlogic/pi-skills` 那类官方 collection 是一份目录索引,进它要提 PR。 +- 三条出口共用同一道门禁(skill-pack),差别只在「装什么、给谁」;不发布就不用声明、不用建仓。镜像仓/包仓都是生成物——**改代码只改技能目录**。 + +--- + +## 13. 增量验证、巡检与退役(Phase 6) + +### 13.1 提交前只跑该跑的(`skillcheck --changed []`) + +``` +skillcheck --changed # 对比工作区(含未跟踪文件) +skillcheck --changed origin/main # 对比已提交区间 ...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` 升上来,升级前自动备份 `.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 与自限两条)。 diff --git a/skills/skill-interface/SKILL.md b/skills/skill-interface/SKILL.md new file mode 100644 index 0000000..5891f30 --- /dev/null +++ b/skills/skill-interface/SKILL.md @@ -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=` + `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`,不要凭记忆拼参数。 diff --git a/skills/skill-interface/VERSION b/skills/skill-interface/VERSION new file mode 100644 index 0000000..6e8bf73 --- /dev/null +++ b/skills/skill-interface/VERSION @@ -0,0 +1 @@ +0.1.0 diff --git a/skills/skill-interface/contract.lock.json b/skills/skill-interface/contract.lock.json new file mode 100644 index 0000000..f9ee2a8 --- /dev/null +++ b/skills/skill-interface/contract.lock.json @@ -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" + ] +} diff --git a/skills/skill-interface/interface.json b/skills/skill-interface/interface.json new file mode 100644 index 0000000..a5f824a --- /dev/null +++ b/skills/skill-interface/interface.json @@ -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": "别的技能建在它上面" + } +} diff --git a/skills/skill-interface/references/user-guide.md b/skills/skill-interface/references/user-guide.md new file mode 100644 index 0000000..84f2c23 --- /dev/null +++ b/skills/skill-interface/references/user-guide.md @@ -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 []]` —— 合规总闸 + +``` +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 []` —— 提交前的最短路径 + +``` +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` 给对方(解包后顶层目录就是技能名,直接放进对方的 `/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 [] 提交前最短路径 +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=有待处理) +``` diff --git a/skills/skill-interface/scripts/install.sh b/skills/skill-interface/scripts/install.sh new file mode 100644 index 0000000..a886e79 --- /dev/null +++ b/skills/skill-interface/scripts/install.sh @@ -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" "$@" diff --git a/skills/skill-interface/scripts/lib/args.mjs b/skills/skill-interface/scripts/lib/args.mjs new file mode 100644 index 0000000..b07e628 --- /dev/null +++ b/skills/skill-interface/scripts/lib/args.mjs @@ -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} spec 命令自己的旗标 + * @param {{skill?: string, command?: string}} where 只用于报错提示:pi-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; +} diff --git a/skills/skill-interface/scripts/lib/dupguard.mjs b/skills/skill-interface/scripts/lib/dupguard.mjs new file mode 100644 index 0000000..8682d1f --- /dev/null +++ b/skills/skill-interface/scripts/lib/dupguard.mjs @@ -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 }; +} diff --git a/skills/skill-interface/scripts/lib/notify.mjs b/skills/skill-interface/scripts/lib/notify.mjs new file mode 100644 index 0000000..dbc611f --- /dev/null +++ b/skills/skill-interface/scripts/lib/notify.mjs @@ -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) }; +} diff --git a/skills/skill-interface/scripts/lib/render.mjs b/skills/skill-interface/scripts/lib/render.mjs new file mode 100644 index 0000000..245e4fc --- /dev/null +++ b/skills/skill-interface/scripts/lib/render.mjs @@ -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; +} diff --git a/skills/skill-interface/scripts/pi-skill.mjs b/skills/skill-interface/scripts/pi-skill.mjs new file mode 100644 index 0000000..06466f4 --- /dev/null +++ b/skills/skill-interface/scripts/pi-skill.mjs @@ -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); +}); diff --git a/skills/skill-interface/scripts/skill-audit.mjs b/skills/skill-interface/scripts/skill-audit.mjs new file mode 100644 index 0000000..bebb302 --- /dev/null +++ b/skills/skill-interface/scripts/skill-audit.mjs @@ -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 + 依赖图 + 调用留痕(${'/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); diff --git a/skills/skill-interface/scripts/skill-config.mjs b/skills/skill-interface/scripts/skill-config.mjs new file mode 100755 index 0000000..f284dd4 --- /dev/null +++ b/skills/skill-interface/scripts/skill-config.mjs @@ -0,0 +1,200 @@ +#!/usr/bin/env node +/** + * 配置契约原语(库,不是 CLI):随人/随机器变化的值统一走这里。 + * + * 优先级:CLI 参数 > 环境变量 PI_SKILL__ > 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= need=human ask="…" hint="pi-skill X setup -- <值>"` + * - secret: true 的键默认打码,只有 --reveal 才显示明文 + * - 读取一律经本模块(不要自己拼 local/skills 路径),这样 doctor 能一次性体检全库 + * + * 文件格式:`{ "": <值>, …, "_meta": { "": { "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__ 环境变量键名(可用来抓未声明的键)。 */ +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]; +} diff --git a/skills/skill-interface/scripts/skill-contract.mjs b/skills/skill-interface/scripts/skill-contract.mjs new file mode 100755 index 0000000..4796f71 --- /dev/null +++ b/skills/skill-interface/scripts/skill-contract.mjs @@ -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(); diff --git a/skills/skill-interface/scripts/skill-deps.mjs b/skills/skill-interface/scripts/skill-deps.mjs new file mode 100644 index 0000000..6f7ccb8 --- /dev/null +++ b/skills/skill-interface/scripts/skill-deps.mjs @@ -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() }; +} diff --git a/skills/skill-interface/scripts/skill-entry-install.mjs b/skills/skill-interface/scripts/skill-entry-install.mjs new file mode 100644 index 0000000..511afa0 --- /dev/null +++ b/skills/skill-interface/scripts/skill-entry-install.mjs @@ -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); diff --git a/skills/skill-interface/scripts/skill-invocation-log.mjs b/skills/skill-interface/scripts/skill-invocation-log.mjs new file mode 100644 index 0000000..e140328 --- /dev/null +++ b/skills/skill-interface/scripts/skill-invocation-log.mjs @@ -0,0 +1,206 @@ +/** + * skill-invocation-log — pi-skill 调用留痕的原语(库,不是 CLI)。 + * + * 目的:让「哪些原语没人用 / 哪些接口老出错 / 哪些技能已腐烂」有数据可答。 + * 边界:只记形状不记内容——参数个数与旗标名,不记参数值、不记输出正文。 + * + * 日志:/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=;仅在退出码非 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前`; +} diff --git a/skills/skill-interface/scripts/skill-pack.mjs b/skills/skill-interface/scripts/skill-pack.mjs new file mode 100755 index 0000000..e3b42ae --- /dev/null +++ b/skills/skill-interface/scripts/skill-pack.mjs @@ -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 级发现。 + * 产出:/<技能>-<版本>/ + 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 ? '这两个(技能 + 依赖)' : ''}目录都放进 /skills/ 后跑 pi-skill doctor;缺配置它会自己说要什么` + : 'hint=对方把目录放进 /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); +} diff --git a/skills/skill-interface/scripts/skill-registry.mjs b/skills/skill-interface/scripts/skill-registry.mjs new file mode 100644 index 0000000..2d547d0 --- /dev/null +++ b/skills/skill-interface/scripts/skill-registry.mjs @@ -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); diff --git a/skills/skill-interface/scripts/skill-release.mjs b/skills/skill-interface/scripts/skill-release.mjs new file mode 100644 index 0000000..3e50e1a --- /dev/null +++ b/skills/skill-interface/scripts/skill-release.mjs @@ -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": "", "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); + } +} diff --git a/skills/skill-interface/scripts/skill-retire.mjs b/skills/skill-interface/scripts/skill-retire.mjs new file mode 100644 index 0000000..815874e --- /dev/null +++ b/skills/skill-interface/scripts/skill-retire.mjs @@ -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); diff --git a/skills/skill-interface/scripts/skill-state.mjs b/skills/skill-interface/scripts/skill-state.mjs new file mode 100644 index 0000000..63fdaf9 --- /dev/null +++ b/skills/skill-interface/scripts/skill-state.mjs @@ -0,0 +1,98 @@ +#!/usr/bin/env node +/** + * skill-state — 本机私有状态文件(`local/skills/<技能>/state.json`)的版本化原语(库,不是 CLI)。 + * + * 为什么需要:状态文件随技能演进会换形状(字段改名、结构升级)。没有版本号时, + * 升级后的技能读到老文件只能猜——猜错就静默丢数据。约定: + * - 每条状态都带 `_schemaVersion`;当前版本由调用方声明; + * - 读到的版本比当前低 → 调 migrate 升上来,升级前先备份(.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; +} + +/** 备份:.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)})` : ''}`; +} diff --git a/skills/skill-interface/scripts/skillcheck.mjs b/skills/skill-interface/scripts/skillcheck.mjs new file mode 100644 index 0000000..fb16f2a --- /dev/null +++ b/skills/skill-interface/scripts/skillcheck.mjs @@ -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 []] + --static 只做静态检查(不跑 help) + --quiet 只打印有问题的项 + --changed 只检查「改动了的技能 + 依赖它的技能」(给出待跑清单,见 REFERENCE §13) + 不带 ref 时对比工作区,带 ref(如 origin/main)时对比 git diff ...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": ""(守护如 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.` 代码模式正则太脆,见 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=;缓存只作补充,不能单独当答案'); + } + 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);