This commit is contained in:
2026-09-10 17:12:11 +08:00
58 changed files with 3386 additions and 513 deletions
+1
View File
@@ -31,3 +31,4 @@ Thumbs.db
# 本地环境配置(勿提交密钥)
.env
.env.*
.pouch
+27 -32
View File
@@ -12,16 +12,14 @@
## 快速开始
需要 Python 3、git,以及 Cursor / Claude Code / Codex 或其它兼容 Agent Skills 的 Agent。人类用法见根 [README.md](README.md) 和各 skill 的 `README.md`。用户要代装时:pouch skill 已安装则走其「安装 pouch」模式;否则读 [skills/pouch/references/install.md](skills/pouch/references/install.md) 并执行。不要只用 `pouch select`。
```bash
# 1. 克隆并关联
git clone https://git.yumee.top/laily/pouch.git ~/.pouch
cd ~/.pouch && ./install.sh
# install.sh 会自动把 pouch 项目 skill 安装到所有 Agent
# 2. 安装其他 skill
pouch add declarative-openspec-loop -g
# 3. 查看状态
# install.sh 只把 pouch skill 装到所有 Agent;其它 skill 用 select / add
pouch select -g
pouch list
pouch status
```
@@ -31,15 +29,7 @@ pouch status
## 仓库结构
```
skills/
├── _template/ # 新建 skill 的脚手架
├── ack/ # 完整 ACK skill,含 references/templates/scripts
├── declarative-openspec-loop/ # 自研 skill,必须含 SKILL.md
│ ├── SKILL.md
│ └── reference.md
├── discussion-notes/ # 讨论沉淀笔记
│ ├── SKILL.md
│ └── reference.md
skills/ # 自研 skill(SSOT):每个子目录含 SKILL.md(Agent)和 README.md(人类)
pouch/ # CLI 源码(Python 3)
bin/pouch # CLI 入口
catalog.yaml # pouch 预置 Skill 来源目录
@@ -57,15 +47,23 @@ AGENTS.md # 本文档
| Skill | 说明 |
| ---------------------------------------------------------------------- | ------------------------------------------------- |
| [orc](skills/orc/SKILL.md) | ORC 入口:显式编排开发、版本发布与产物任务,支持 Agent 分档 |
| [ack](skills/ack/SKILL.md) | ACK 入口:显式初始化、检查并运行三角色闭环、测试环境、发版与回归 |
| [pouch](skills/pouch/SKILL.md) | 本项目工作流:创建、使用、反馈与更新 builtin skill |
| [declarative-openspec-loop](skills/declarative-openspec-loop/SKILL.md) | 声明式编程循环:用户提供校验方式,Agent 自动 propose/apply/校验并迭代直到通过 |
| [discussion-notes](skills/discussion-notes/SKILL.md) | 讨论沉淀:边讨论边维护 Markdown 笔记,无 .raw.md |
| --- | --- |
| [ack](skills/ack/README.md) | 显式初始化、检查并运行三角色闭环:需求 / bug、测试环境、发版与回归 |
| [builder](skills/builder/README.md) | 按统一契约构建和发布 DEB / Docker 镜像 |
| [declarative-openspec-loop](skills/declarative-openspec-loop/README.md) | 你给校验方式,Agent 自动 propose / apply / 校验直到通过 |
| [deployer](skills/deployer/README.md) | 用 Git 里的 Compose 或 Argo CD 清单部署服务 |
| [discussion-notes](skills/discussion-notes/README.md) | 边讨论边维护 Markdown 笔记 |
| [draw-prototype-flow](skills/draw-prototype-flow/README.md) | 把产品想法画成可编辑的 Draw.io 低保真原型和用户动线 |
| [eli5](skills/eli5/README.md) | 用大图少字的单页 HTML 把话题讲给完全没背景的人听 |
| [host-sec-check](skills/host-sec-check/README.md) | 对 Linux/VPS 做入侵取向的安全检查,默认只出报告 |
| [manage-release](skills/manage-release/README.md) | 给已准备好的 commit 发版本:更新说明、tag、可选 Forge Release |
| [memory-distill](skills/memory-distill/README.md) | 任务结束后把可复用结论写入通用记忆和项目记忆 |
| [note-style](skills/note-style/README.md) | 把材料整理成可长期查阅的知识笔记 |
| [orc](skills/orc/README.md) | 把开发、发版、DEB / Docker 意图编排成阶段,按档位分发给对应 skill |
| [pouch](skills/pouch/README.md) | 创建、校验、安装和维护 builtin skill |
新建 skill:复制 `skills/_template/` → `skills/<name>/`,编辑 `SKILL.md`,在本仓库 commit。
新建 skill:优先 `pouch create`;或复制 `skills/_template/` → `skills/<name>/`,编辑 `SKILL.md` 和 `README.md`,在本仓库 commit。
Skill 需要的稳定规范、模板、示例和脚本直接放在自己的目录中,例如
`skills/ack/references/`、`templates/`、`examples/` 和 `scripts/`。项目初始化只生成
@@ -164,12 +162,6 @@ catalog.yaml ←── pouch add / fetch
| **Custom Source** | `~/.local/share/pouch/sources/` 或本地路径 | `pouch source add/fetch` |
| **External NPM** | `node_modules/` | `npx skills add` / `skills-npm` |
### 非 Skill 资料分类
| 类型 | 位置 | 维护方式 |
| --- | --- | --- |
| **OpenSpec 配置** | `openspec/` | 本仓库 commit;服务于本仓库自身的规格流程 |
### 多 Agent 路径
@@ -260,7 +252,8 @@ pouch sync
```
skill-name/
├── SKILL.md # 必需
├── SKILL.md # 必需,给 Agent
├── README.md # 必需,给人类
├── reference.md # 可选
├── examples.md # 可选
└── scripts/ # 可选
@@ -285,7 +278,7 @@ description: >-
### 新建流程
1. `pouch create my-skill --idea "..." --from-project .`
2. Agent 编辑 `~/.pouch/.drafts/my-skill/SKILL.md`
2. Agent 编辑 `~/.pouch/.drafts/my-skill/SKILL.md` 和 `README.md`
3. `pouch check my-skill`
4. 用户确认后执行 `pouch finalize my-skill`
5. `pouch add my-skill -a cursor -g -y` 验证
@@ -340,9 +333,11 @@ Claude Code 对 symlink 支持不稳定:可能无法发现 skill,或写入
| 我要… | 命令 | 在哪 |
| ---------- | --------------------------------- | ---- |
| 首次安装 | `git clone <repo> ~/.pouch && ~/.pouch/install.sh` | 任意 |
| 让 Agent 代装 | 说「帮我安装 pouch」或丢仓库 URL | 任意 |
| 介绍 / 接入 skill | 说「有哪些 skill」或「把这个项目该用的 skill 接上」 | 项目目录 |
| 新建 skill | `pouch create` → Agent 完善 → `check/finalize` | 任意项目 |
| 全局启用 | `pouch install <name>` | 任意 |
| 项目启用 | `pouch enable <name>` | 项目目录 |
| 全局启用 | `pouch add <name> -g` | 任意 |
| 项目启用 | `pouch add <name>` 或 `pouch enable <name>` | 项目目录 |
| 看状态 | `pouch status` | 任意 |
| 装社区 skill | `npx skills add owner/repo -g -y` | 任意 |
| 更新外部 skill | `pouch fetch <name>` | 任意 |
+72 -19
View File
@@ -2,15 +2,28 @@
自研 [Agent Skills](https://agentskills.io) 的单一事实来源(SSOT)。仓库、家目录 `~/.pouch` 和 CLI 都叫 **pouch**。Skill 内容与 CLI 在本仓库一并维护。
pouch 本义是小袋子,这里取做锦囊妙计的锦囊:skill 就是里面的妙计。
给同事看怎么装、怎么打版本、怎么构建,读 [docs/getting-started.md](docs/getting-started.md)。人类用法看各 skill 的 `README.md`;`SKILL.md` 是给 Agent 的指令。用户要你代装时,读 [skills/pouch/references/install.md](skills/pouch/references/install.md) 并执行,不要只把命令贴回去,也不要用 `pouch select`。
## 前置
- Python 3、git
- Cursor、Claude Code、Codex,或其它兼容 Agent Skills 的 Agent
- `~/.local/bin` 需要在 `PATH` 里(`install.sh` 会写入 bash / zsh / fish 的 rc,当前 shell 要重开或 `source` 一次才生效)
## 快速开始
```bash
git clone https://git.yumee.top/laily/pouch.git ~/.pouch
cd ~/.pouch
./install.sh # 安装 CLI,并将 pouch 项目 skill 安装到所有 Agent
./install.sh
pouch add declarative-openspec-loop -g
pouch select # 交互式选择并批量安装
# install.sh 会安装 pouch 命令,并把 pouch 这一个 skill 装到所有 Agent
# 若当前 shell 找不到 pouch:重开终端,或 source 对应 rc
pouch select # 交互选择要装的 skill(默认当前项目)
pouch select -g # 或装到全局,所有项目都能用
pouch list
pouch status
```
@@ -24,34 +37,69 @@ mv ~/.skills ~/.pouch
`install.sh` 会安装 `pouch` 命令,并保留 `pouch` 作为旧命令别名。项目里已有的 `.skills.yaml` 仍可读取;新写入使用 `.pouch.yaml`。
## 让 Agent 代装
对 Agent 说「帮我安装 pouch」,或把仓库地址丢给它。步骤只维护在
[skills/pouch/references/install.md](skills/pouch/references/install.md):
确认 python3/git → 处理 `~/.pouch` → `install.sh` → 用短清单介绍 skill → 问要装哪些。
不要用 `pouch select`。不要在未确认时覆盖已有的 `~/.pouch`。
自己装仍看上面的「快速开始」。
## 装完之后怎么用
1. 打开已安装目标的 Agent(Cursor / Claude Code / Codex 等)。
2. 项目级安装:打开那个项目再说话。全局安装(`-g`):任意项目都可以。
3. 按对应 skill 的 README,把示例请求发给 Agent。
`./install.sh` 只全局安装 `pouch`。其它 skill 都要 `pouch add` 或 `pouch select`。
```bash
cd ~/code/my-app
pouch add ack # 当前项目
pouch add discussion-notes -g # 全局
```
## 仓库结构
```
skills/ # 自研 skill(SSOT):每个子目录必须有 SKILL.md
skills/ # 自研 skill(SSOT):每个子目录含 SKILL.md 和 README.md
pouch/ # CLI 源码(Python 3)
bin/pouch # CLI 入口
catalog.yaml # pouch 预置 Skill 来源目录
AGENTS.md # 详细规范与架构说明
```
| 路径 | 说明 |
|------|------|
| [skills/](skills/) | 自研 skill,每个子目录含 `SKILL.md`,可附带 references、templates 和 scripts |
| ---------------------------- | ----------------------------------------------------- |
| [skills/](skills/) | 自研 skill,每个子目录含给人看的 `README.md` 和给 Agent 的 `SKILL.md` |
| [pouch/](pouch/README.md) | 安装、软链、健康检查 CLI |
| [catalog.yaml](catalog.yaml) | pouch 预置 Skill 来源目录 |
| [AGENTS.md](AGENTS.md) | 设计原则、编写规范、架构详解 |
## 自研 Skill
| Skill | 说明 |
|-------|------|
| [orc](skills/orc/SKILL.md) | 显式编排开发、版本发布和产物任务,支持 low/mid/high Agent 档位 |
| [ack](skills/ack/SKILL.md) | 显式初始化、检查并运行 ACK 三角色协作及可选交付闭环 |
| [pouch](skills/pouch/SKILL.md) | 在项目中创建、安装、反馈和维护 builtin skill |
| [declarative-openspec-loop](skills/declarative-openspec-loop/SKILL.md) | 声明式编程循环:用户提供校验方式,Agent 自动迭代直到通过 |
| [discussion-notes](skills/discussion-notes/SKILL.md) | 讨论沉淀:边讨论边维护 Markdown 笔记 |
ACK 是包含规范、模板与校验脚本的完整 Skill。安装 Skill 后可初始化当前项目状态:
| Skill | 说明 |
| ----------------------------------------------------------------------- | ------------------------------------------- |
| [ack](skills/ack/README.md) | 显式初始化、检查并运行三角色闭环:需求 / bug、测试环境、发版与回归 |
| [builder](skills/builder/README.md) | 按统一契约构建和发布 DEB / Docker 镜像 |
| [declarative-openspec-loop](skills/declarative-openspec-loop/README.md) | 你给校验方式,Agent 自动 propose / apply / 校验直到通过 |
| [deployer](skills/deployer/README.md) | 用 Git 里的 Compose 或 Argo CD 清单部署服务 |
| [discussion-notes](skills/discussion-notes/README.md) | 边讨论边维护 Markdown 笔记 |
| [draw-prototype-flow](skills/draw-prototype-flow/README.md) | 把产品想法画成可编辑的 Draw.io 低保真原型和用户动线 |
| [eli5](skills/eli5/README.md) | 用大图少字的单页 HTML 把话题讲给完全没背景的人听 |
| [host-sec-check](skills/host-sec-check/README.md) | 对 Linux/VPS 做入侵取向的安全检查,默认只出报告 |
| [manage-release](skills/manage-release/README.md) | 给已准备好的 commit 发版本:更新说明、tag、可选 Forge Release |
| [memory-distill](skills/memory-distill/README.md) | 任务结束后把可复用结论写入通用记忆和项目记忆 |
| [note-style](skills/note-style/README.md) | 把材料整理成可长期查阅的知识笔记 |
| [orc](skills/orc/README.md) | 把开发、发版、DEB / Docker 意图编排成阶段,按档位分发给对应 skill |
| [pouch](skills/pouch/README.md) | 创建、校验、安装和维护 builtin skill |
ACK 是包含规范、模板与校验脚本的完整 Skill。安装后可初始化当前项目状态:
```bash
pouch init ack
@@ -62,11 +110,13 @@ pouch init ack --project ~/app
项目可用自然语言让 `/ack` 把测试环境绑到 deployer、维护发版 profile,并在任务
验证通过后收获回归用例。
builder 与 deployer 的项目接入走各自 skill 的「初始化」模式,不要 `pouch init builder` 或 `pouch init deployer`。
新建 skill:
```bash
pouch create my-skill --idea "描述要解决的重复问题" --from-project .
# 由 Agent 完善 ~/.pouch/.drafts/my-skill/SKILL.md
# 由 Agent 完善 ~/.pouch/.drafts/my-skill/SKILL.md 和 README.md
pouch check my-skill
pouch finalize my-skill
pouch add my-skill -g # 全局安装验证
@@ -89,7 +139,7 @@ pouch add <name> -g -a cursor
```yaml
skills:
- name: declarative-openspec-loop
- name: ack
source: builtin
- name: think
source: catalog:waza
@@ -101,9 +151,9 @@ targets: # 可选,默认 all
```
```bash
pouch add declarative-openspec-loop
pouch add ack
pouch sync
pouch remove declarative-openspec-loop
pouch remove ack
```
## Catalog 与 Custom Source
@@ -133,7 +183,7 @@ npx skills find typescript
```
本仓库
├── skills/<name>/ ←── pouch install / enable
├── skills/<name>/ ←── pouch add / select
├── catalog.yaml ←── 预置来源发现与 fetch
└── pouch/ ←── python3 -m pouch
↑
@@ -157,8 +207,10 @@ npx skills find typescript
## 文档
- [docs/getting-started.md](docs/getting-started.md):给同事的安装、打版本、构建教程
- [AGENTS.md](AGENTS.md) — 完整规范、多 Agent 路径、编写约定
- [pouch/README.md](pouch/README.md) — CLI 命令参考与开发说明
- 各 skill 的人类说明见上表 `README.md`
## 参考
@@ -166,3 +218,4 @@ npx skills find typescript
- [Vercel skills CLI](https://github.com/vercel-labs/skills)
- [skills.sh](https://skills.sh)
- [Cursor Skills 文档](https://cursor.com/docs/context/skills)
+234
View File
@@ -0,0 +1,234 @@
# pouch 上手:安装、打版本、构建
pouch 是团队自研 skill 的仓库和安装工具。Skill 是给 Agent 看的工作说明书。你对着 Cursor、Claude Code 或 Codex 说话,Agent 按说明书干活。这份仓库里还有 CLI,用来把 skill 装到各 Agent。
这篇只讲日常会碰到的三件事:把 pouch 装上、用 manage-release 打正式版本、用 builder 构建 DEB 或 Docker 镜像。其它 skill 装上就能喊,需要时对 Agent 说「有哪些自研 skill」。
## pouch 怎么工作
Skill 的正文只存在一份,在 `~/.pouch/skills/<name>/`。装到 Cursor、Claude、Codex 时走软链,改一份全处生效。项目根的 `.pouch.yaml` 记录这个仓库启用了哪些 skill,类似 `package.json` 记依赖。
日常几乎不直接跑 skill 里的脚本。CLI 负责安装和维护,干活是对 Agent 说人话。授权写在请求里:没说 push、没说上传,Agent 就不做。
| 你想做的 | 对谁说 | 不要 |
| ------------------- | -------------- | ----------------------- |
| 打正式版本、annotated tag | manage-release | 让 builder 选下一个 SemVer |
| 打 DEB 或 Docker 镜像 | builder | 让 manage-release 去构建或上传 |
manage-release 选定正式 SemVer 并打 annotated tag。builder 只读当前 HEAD:HEAD 恰好落在 `vX.Y.Z` 上就打正式包,否则打测试包。
## 安装 pouch
需要 Python 3、git,以及至少一种兼容 Agent Skills 的 Agent。
```bash
git clone https://git.yumee.top/laily/pouch.git ~/.pouch
cd ~/.pouch && ./install.sh
export PATH="$HOME/.local/bin:$PATH"
pouch status
```
`install.sh` 会把 `pouch` 命令放到 `~/.local/bin`,并把 pouch 这一个 skill 全局装到所有 Agent。其它 skill 不会自动全装。当前 shell 找不到 `pouch` 时,重开终端,或 `source` 对应 rc。
也可以对 Agent 说「帮我安装 pouch」,或把仓库地址丢给它。不要覆盖已经存在的 `~/.pouch`。
从旧目录迁过来:
```bash
mv ~/.skills ~/.pouch
~/.pouch/install.sh
```
装完看一眼:
```bash
pouch list
pouch status
pouch doctor
```
软链异常就 `pouch doctor --fix`。更新 pouch 自身:`pouch update`(在 `~/.pouch` 里 `git pull`)。
## 把要用的 skill 装上
发版本和构建这两个,全局装一次,所有项目都能喊:
```bash
pouch add manage-release -g -y
pouch add builder -g -y
```
只想在当前仓库启用:
```bash
cd ~/code/my-app
pouch add manage-release -y
pouch add builder -y
```
也可以对 Agent 说「把这个项目该用的 skill 接上」,它会按仓库信号建议,再 `pouch add`。builder 不要 `pouch init builder`,初始化是对 Agent 说「用 builder 初始化这个项目」。
交互多选:`pouch select -g`。取消勾选不会卸载,卸载用 `pouch remove`。
## 怎么对 Agent 说话
打开已安装目标的 Agent。项目级安装要打开那个项目再说话;全局安装任意项目都可以。请求里写清 skill 名、目标和授权范围。
```text
使用 manage-release,为已经合并到 main 的 1.6.0 发布 v1.6.0 tag。不要创建 Forge Release。
用 builder 初始化这个项目。
用 builder 构建当前版本的 DEB,先不要上传。
```
人看各 skill 目录下的 `README.md`。`SKILL.md` 是给 Agent 的指令,不必通读。
## 用 manage-release 打新版本
代码已经合进默认分支、测试过了,要打正式 tag 时用它。它不构建 DEB,也不推镜像。
### 还没决定版本号
```text
使用 manage-release 检查当前版本和这次改动,建议下一个版本号。只分析,不修改文件。
```
Agent 会读项目版本规则、现有 tag 和实际改动,给出建议和依据。没有项目约定时,单一协调版本默认 SemVer,tag 形如 `v1.6.0`。用户输入 `v1.6.0` 时规范版本是 `1.6.0`,不要产生双前缀。
### 默认短路径:直接打 tag
项目以 Git tag 作为版本来源,且不需要改 VERSION 文件或把 CHANGELOG 写回仓库时,走这条:
```text
使用 manage-release,为已经合并到 main 的 1.6.0 发布 v1.6.0 tag。根据上一个稳定 tag 到目标 commit 的实际变化生成更新说明,把说明写入 annotated tag,并验证远端 tag 的 commit 和说明。不要创建 Forge Release。
```
请求里写清目标版本、基线分支、打到哪一步。没写 push、没写创建平台 Release,Agent 就不会做。只发 Git tag 时不要求 GitHub / GitLab / Gitea CLI,但 Git remote 必须可读写。
Agent 会锁定远端目标 commit,生成带 Summary、Changes、Validation、Base commit 的更新说明,创建 annotated tag 并推送,再从远端核对。不会给未合并分支打正式 tag,也不会覆盖已有同名 tag。
### 需要改版本文件时走长路径
权威版本文件或仓库内 CHANGELOG 必须随发布改掉时,才创建 `release/v1.6.0` 这类分支和 PR。可以一次说完,也可以拆开授权:
```text
使用 manage-release 完成 1.6.0 发布:从 main 创建分支,完成版本更新和验证,创建 PR,检查通过后合并,推送 v1.6.0 tag。不要创建 Forge Release。
```
中途停了,下次说「使用 manage-release 检查 v1.6.0 当前发布状态,从尚未完成的步骤继续。不要重复创建分支、PR、tag 或 release。」
要在 Gitea / GitHub 上建平台 Release 时,在请求里写明。没写就不会建。Forge Release 复用 tag 里同一份说明。
### 判断打没打成
远端 tag 解引用后的 commit 就是目标 commit,tag object 里有完整更新说明。只分析时会返回建议版本和依据。卡在某步时会说明停在哪、下次怎么续,不会用「发布成功」盖住未完成的步骤。
## 用 builder 构建项目
builder 按统一契约打 DEB 和 Docker 镜像。项目根会多两个文件:`makefile.builder`(构建入口)和 `.env.builder`(发布凭据,不进 git)。它不改你已有的 `Makefile` 或 `.env`。
正式版本号仍由 manage-release 打 tag。builder 只读当前 HEAD,不调用 manage-release。
### 接入一个项目
需要 Python 3 和 `make`。打 DEB 还要 `dpkg-deb`,打镜像还要 Docker 和 buildx。在项目根对 Agent 说:
```text
用 builder 初始化这个项目。
```
Agent 会探测轨道、写出 `makefile.builder`,缺发布配置时列出来。然后在 `.env.builder` 填仓库地址和 token:
```text
DEB_SERVER_URL=https://deb.example.com
DEB_REPOSITORY=main
DEB_TOKEN=<token>
DOCKER_REGISTRY=registry.example.com
```
token 只放 `.env.builder` 或密钥系统,不要写进 `.env`,也不要进 git。检查契约:
```text
用 builder 检查这个项目的 makefile.builder 是否符合契约。
```
构建项通过就可以打本地包。缺发布键只挡住上传。
日常构建入口:
```bash
make -f makefile.builder help
make -f makefile.builder version
make -f makefile.builder build
make -f makefile.builder deb
```
### 版本从哪来
`make -f makefile.builder version` 输出一行规范版本,无 `v` 前缀。基线是 HEAD 祖先上最近的稳定 tag,稳定 tag 仅匹配 `v<major>.<minor>.<patch>`,不含 `-rc` 这类后缀。
HEAD 恰好是 `v1.6.0` 时,产物是正式的 `1.6.0`。其它 commit 打测试号:祖先最近稳定 tag 的 patch 机械 +1,再挂构建日、分支、距离、短 SHA。测试 DEB 用 `~`,于是 `1.6.0` &lt; `1.6.1~…` &lt; `1.6.1`。已装上一正式版的机器可以升到 bugfix 测试包;同号正式包仍盖住测试。
分支 `feat/login-v2`,相对 `v1.6.0` 第 7 个 commit,短 SHA `abc1234`,构建日 `20260908`:
```text
规范 / DEB 1.6.1~20260908.feat-login-v2.7+gabc1234
Docker registry/ns/foo:1.6.1-20260908.feat-login-v2.7.gabc1234
```
不要自己 `git tag | sort -V | tail -1` 取全仓库最大号,也不要把 `git describe --dirty` 的原始字符串写进包名。
### 构建和发布
只构建:
```text
用 builder 构建当前版本的 DEB 和镜像,先不要上传。
```
确认后再上传:
```text
用 builder 把当前 commit 的产物发布到项目已配置的仓库。
```
自己跑也可以:
```bash
make -f makefile.builder deb
make -f makefile.builder push-deb
```
Docker 只有点名镜像时 Agent 才走。脏工作树默认拒绝发布。可能覆盖同版本时会先问你。token 不会出现在输出、提交或回复里。
## 一条完整发布链
代码合进 `main` 且检查通过之后,按这个顺序:
1. 对 Agent:`使用 manage-release,为已经合并到 main 的 1.6.0 发布 v1.6.0 tag。不要创建 Forge Release。`
2. 确认远端 `v1.6.0` 指向那次 commit。
3. checkout 到该 tag,或确保当前 HEAD 就是它。
4. 对 Agent:`用 builder 构建当前版本的 DEB,先不要上传。`
5. 核对 `make -f makefile.builder version` 输出 `1.6.0`,再授权上传。
先构建再打 tag,得到的是测试包,版本号会变成下一 patch 的 `~` 形式,不是正式号。
## 常见卡住的地方
- 当前 shell 找不到 `pouch`:`~/.local/bin` 还没进 PATH,重开终端或 `source` 对应 rc。
- 在 `~/.cursor/skills/` 里直接改文件:改的是软链目标,应该改 `~/.pouch/skills/`。Claude Code 有时会把软链替换成普通文件,用 `pouch doctor --fix` 重建。
- `pouch init builder` 这条路径不存在:对 Agent 说「用 builder 初始化」。
- 请求里没写清授权:Agent 会分析或只做到本地,不会擅自 push tag 或上传包。
- 工作区有未提交修改:builder 默认拒绝发布;manage-release 不会 stash 或覆盖你的文件。
- 同名 tag 已在远端指向别的 commit:manage-release 会停,不会 force push。
- HEAD 不在稳定 tag 上却想打正式包:先打 tag,再构建。
## 还想看什么
- 仓库总览:[README.md](../README.md)
- CLI 命令:[pouch/README.md](../pouch/README.md)
- manage-release:[skills/manage-release/README.md](../skills/manage-release/README.md)
- builder:[skills/builder/README.md](../skills/builder/README.md)
- 设计原则和编写规范:[AGENTS.md](../AGENTS.md)
-20
View File
@@ -1,20 +0,0 @@
schema: spec-driven
# Project context (optional)
# This is shown to AI when creating artifacts.
# Add your tech stack, conventions, style guides, domain knowledge, etc.
# Example:
# context: |
# Tech stack: TypeScript, React, Node.js
# We use conventional commits
# Domain: e-commerce platform
# Per-artifact rules (optional)
# Add custom rules for specific artifacts.
# Example:
# rules:
# proposal:
# - Keep proposals under 500 words
# - Always include a "Non-goals" section
# tasks:
# - Break tasks into chunks of max 2 hours
+2 -1
View File
@@ -201,7 +201,7 @@ pouch doctor -a cursor
```bash
cd ~/code/my-app
pouch add declarative-openspec-loop -a cursor -y
pouch add discussion-notes -a cursor -y
```
### 添加 Catalog Source
@@ -251,3 +251,4 @@ pouch/
- [项目 README](../README.md)
- [AGENTS.md](../AGENTS.md)
- 各 skill 的人类说明:`skills/<name>/README.md`
+54 -265
View File
@@ -1,28 +1,25 @@
# ACK
ACK 是一个显式调用的 Agent Skill,用三种独立角色运行工程协作闭环:
ACK 是显式调用的 Agent Skill,用三种独立角色跑工程协作闭环:
- Coordinator 拆解需求、派发任务并终检。
- Developer 实现并执行白盒验证。
- Test 独立执行黑盒复测。
- Coordinator 拆解需求、派发任务并终检
- Developer 实现并做白盒验证
- Test 独立做黑盒复测
关键约束是验证者不等于实现者。每个任务最多修复三轮,仍未通过时记录为
`leftover`,然后继续处理其它任务。
验证者不能等于实现者。每个任务最多修三轮,仍不过就记为 `leftover`,然后继续别的任务。
测试环境部署由 ACK 触发、**内部调用 deployer** 执行。发版仍写在同一份
`.pouch/ack/delivery.yaml`。功能或 bug 验证通过后,把黑盒用例收进
`.pouch/ack/regression.yaml`;之后可以单独跑回归。
测试环境由 ACK 触发、内部调用 [deployer](../deployer/README.md)。发版写在同一份 `.pouch/ack/delivery.yaml`。功能或 bug 验证通过后,黑盒用例收进 `.pouch/ack/regression.yaml`,之后可以单独跑回归。
ACK 只在用户显式调用 `/ack` 或 `$ack` 时运行。
只在你显式说 `/ack` 或 `$ack` 时运行。
## 使用场景
## 什么时候使用
| 场景 | 怎么说 | 结果 |
| --- | --- | --- |
| 初始化 | `/ack 初始化` | 生成并补全 `.pouch/ack/`;缺项按统一格式列出 |
| 检查 | `/ack 检查配置` | 只读校验,默认不改文件;测试环境缺 deployer 时转交 |
| 做需求 | `/ack 处理这个需求:…` | 产品文档 + 拆任务 → 确认 → 三角色闭环 |
| 修 bug | `/ack 修这个 bug:…` 或处理飞书收件 | 短描述 + 验收 → 确认(飞书须你点「已确认」)→ 同一闭环 |
| 做需求 | `/ack 处理这个需求:…` | 产品文档 + 拆任务 → 你确认 → 三角色闭环 |
| 修 bug | `/ack 修这个 bug:…` 或处理飞书收件 | 短描述 + 验收 → 你确认(飞书须你点「已确认」)→ 同一闭环 |
| 交付配置 | 说明怎么布测试环境 / 怎么发版 | 写入同一份 `delivery.yaml`;测试环境绑定 deployer |
| 运行测试环境 | `/ack 重新布测试环境` | 内部加载 deployer,布 `.pouch/deployer/<env>` |
| 运行版本发布 | `/ack 发布一个版本` | 按 `intents.release`;stable/生产仍要单独批准 |
@@ -32,17 +29,18 @@ ACK 只在用户显式调用 `/ack` 或 `$ack` 时运行。
## 安装
全局安装:
```bash
pouch add ack -g
pouch add ack # 当前项目
pouch add ack -g # 全局
```
或只安装到当前项目:
## 使用前准备
```bash
pouch add ack
```
- 项目是 Git 仓库。
- 自动拉起 Developer / Test worker 时需要 Orca,以及项目里配置好的 Codex、Cursor、Grok 或 OMP。
- 布测试环境需要同时安装 [deployer](../deployer/README.md),并准备好 SSH 和目标机器的 Docker Compose。
- 飞书收 bug 是可选的,要在 `project.md` 里配置 `bugIntake`。
- 不要 `pouch init builder` 或 `pouch init deployer`。测试环境和发版直接对 `/ack` 说;缺 deployer 时 ACK 会转去它的初始化。
## 初始化项目
@@ -51,7 +49,7 @@ pouch init ack
pouch init ack --project ~/code/my-app
```
初始化后,项目只保存自己的 ACK 状态:
也可以直接说 `/ack 初始化`。初始化后项目只保存自己的状态:
```text
.pouch/ack/
@@ -62,271 +60,62 @@ pouch init ack --project ~/code/my-app
└── regression.yaml # 默认 cases: []
```
不会在项目中复制或链接 ACK Skill。通用规范、模板和脚本始终从已安装的 Skill
目录读取。
不会把 ACK Skill 复制进项目。规范、模板和脚本始终从已安装的 Skill 目录读取。
ACK 从当前命令指定的 `--project-root/.pouch/ack/` 定位项目状态,不要求在
`tasks.yaml` 中持久化 `repoPath` 或 `devWorktree`。自动 worker 的实际工作目录由
`--worktree` 指定;默认在 `--project-root` 工作,v0.19 起不再配置 `allowedWorktrees` 白名单。
旧项目如果只有 `project.md` 和 `tasks.yaml`,不要重跑初始化。说 `/ack 检查配置`,授权后再补空的 `knowledge.yaml` 等缺件。
## Skill 结构
```text
skills/ack/
├── SKILL.md
├── README.md
├── VERSION
├── references/ # 三角色规范、闭环流程和初始化说明
├── templates/ # project.md、tasks.yaml、knowledge.yaml、delivery.yaml、regression.yaml 模板和 schema
├── examples/ # 完整示例
└── scripts/ # 状态校验、任务/知识/回归选择、安全验证执行与结构化 worker launcher
```
`SKILL.md` 是 Agent 的工作流入口。`references/` 是按需读取的稳定规范;
`.pouch/ack/project.md` 只保存当前项目的命令、路径和权限差异;
`.pouch/ack/tasks.yaml` 保存当前任务状态;`.pouch/ack/knowledge.yaml` 保存跨任务复用、
已经独立验证的项目知识护栏。
`.pouch/ack/delivery.yaml` 是测试环境绑定和版本发布的唯一契约;测试环境由 ACK
内部调用 deployer,运行证据记在 `tasks.yaml.deliveryRuns`。
`.pouch/ack/regression.yaml` 是黑盒回归用例目录,运行证据记在
`tasks.yaml.regressionRuns`。
## 检查项目状态
Agent 会从当前 ACK Skill 目录解析校验脚本:
```bash
python3 <ack-skill-dir>/scripts/validate_tasks.py .pouch/ack/tasks.yaml
python3 <ack-skill-dir>/scripts/validate_knowledge.py .pouch/ack/knowledge.yaml \
--tasks .pouch/ack/tasks.yaml
python3 <ack-skill-dir>/scripts/validate_delivery.py .pouch/ack/delivery.yaml \
--tasks .pouch/ack/tasks.yaml --project-root <project-root>
python3 <ack-skill-dir>/scripts/validate_regression.py .pouch/ack/regression.yaml \
--tasks .pouch/ack/tasks.yaml
```
Coordinator 可以按当前任务上下文做确定性推荐:
```bash
python3 <ack-skill-dir>/scripts/select_tasks.py .pouch/ack/tasks.yaml
python3 <ack-skill-dir>/scripts/select_tasks.py .pouch/ack/tasks.yaml \
--task-id BUG-001
python3 <ack-skill-dir>/scripts/select_knowledge.py .pouch/ack/knowledge.yaml \
--component web --path web/app.py --tag long-running-service --limit 10
python3 <ack-skill-dir>/scripts/select_regression.py .pouch/ack/regression.yaml
python3 <ack-skill-dir>/scripts/select_regression.py .pouch/ack/regression.yaml \
--suite full --case-id REG-login-001
```
任务选择器会解析并执行完整任务板的内置语义校验,但只输出 `project`、`summary`、
默认可工作状态或显式 `--task-id` 命中的任务,以及这些任务引用的 receipt 和 delivery
run。默认最多 20 条,超过预算时显式失败;Agent 不应回退为把完整 `tasks.yaml` 注入
上下文。
默认 JSON 输出会同时给出固定知识引用和已解析的 `verificationTarget.path/args`;
选择器只输出数据,不执行检查。`scope.all=true` 的全项目 active 规则优先占用
`--limit`;如果全项目规则本身超过预算,选择器会显式失败,不会静默漏派。
需要执行知识项引用的检查时,只传 registry ID 给 ACK 的安全执行入口:
```bash
python3 <ack-skill-dir>/scripts/run_verification.py \
.pouch/ack/knowledge.yaml check-api-contract --project-root <project-root>
```
该入口会在执行前重新校验知识库,只打开一次项目根目录 fd,再从同一个 fd 逐段以
`O_NOFOLLOW` 打开知识库和检查文件;检查内容复制到匿名、尽可能 sealed 的稳定
快照,再以结构化 argv 和 `shell=False` 启动。它不接受临时命令或额外参数。
选择器输出的 path/args 只用于审阅,不应由 Agent 自行拼接执行。Runner 只读取
项目内无 symlink 的权威
`.pouch/ack/knowledge.yaml`,不接受替代知识文件或放宽后的项目根。检查进程的 cwd
和 `ACK_PROJECT_ROOT` 都固定到该根 fd;后者是只在检查进程存活期间有效的
`/proc/self/fd/...` 或 `/dev/fd/...` 路径。原始可读路径另放在
`ACK_PROJECT_ROOT_DISPLAY`,只能用于日志,不能用于资源访问。Runner 还提供
`ACK_VERIFICATION_REF` 和 `ACK_VERIFICATION_PATH`;检查脚本必须据此定位资源,
不能依赖 `$0` 或 `__file__` 所在目录,因为实际执行的是匿名快照。
知识先由 Developer 或 Test 作为 `candidate` 提名,经独立验证和 Coordinator gate
后才能成为 `active`。Coordinator 按路径、组件、依赖、版本和标签推荐相关知识,
确认后将固定 revision 的 `knowledgeRefs` 写入任务上下文;每轮只派发命中的少量
条目,不全量注入知识库。
旧项目只有 `project.md` 和 `tasks.yaml` 时,不要重跑初始化。由 `/ack` 检查现有
状态,获得用户授权后补一个空的 `knowledge.yaml`;如果任务板尚未声明知识库,
同时只补 `project.knowledgeFile: .pouch/ack/knowledge.yaml`,再运行跨文件校验。
只有 Coordinator 写 `tasks.yaml`、`knowledge.yaml` 和 `regression.yaml`。知识正文
不能作为自由 shell 执行;关键约束应继续下沉到测试、lint、CI 或正式规范。ACK
不自动修改项目的 `AGENTS.md`、`CLAUDE.md` 或其它 Agent 指令文件。
## 配置与运行交付
测试环境走 deployer 的项目内布局(通常是 `.pouch/deployer/test`),发版仍用
delivery profile。可以直接说:
```text
/ack 测试环境用项目里的 .pouch/deployer/test;发版方式以后再告诉你。
```
ACK 把测试环境写成 `intents.testEnvironment.via: deployer`,把发版写成
`intents.release` 指向的 profile。`.pouch/deployer/<env>` 还没就绪时,ACK
会转去 deployer 的初始化,而不是自己编 compose。首次配置保持关闭,确认后才启用。
之后可以说:
## 使用示例
```text
/ack 初始化
/ack 检查配置
/ack 处理这个需求:给后台加导出 CSV
/ack 修这个 bug:保存后列表不刷新。复现:… 期望:…
/ack 处理飞书里待整理的 bug
/ack 测试环境用项目里的 .pouch/deployer/test;发版方式以后再告诉你
/ack 重新布一下测试环境,我要测试
/ack 发布一个版本
```
对应 intent 未配置时先问清楚并写回同一文件,不猜测。运行测试环境时 ACK 加载
deployer skill,不复制 compose 命令。本地进程启动写在 `project.md`,不算这个
模式。intent 运行不要求当前有 `verified` 任务;`deliveryRuns.intent` 记录是
测试环境还是发版。
交付配置只允许声明式工具 target 或仓库内可执行脚本,不接受自由 shell,也不保存
凭据值。任务进入 `verified` 后的常规交付仍按确认过的 profile 执行。默认
profile 只能停在 `validation_ready` 或 `review_ready`;稳定发布或生产部署必须经过
对应 approval 步骤。具体契约见 `references/delivery.md`。
## 启动 Worker
worker 的机器配置位于 `tasks.yaml.project.orchestration`:项目显式维护模型
allowlist、结构化 profile、默认 profile 和允许的 worktree。不得在 `project.md`
或任务里保存完整启动命令、额外 argv 或环境变量。
先审阅计划,不产生终端:
```bash
python3 <ack-skill-dir>/scripts/launch_worker.py plan \
--project-root <project-root> \
--task-id <task-id> \
--attempt-id <task-id>-A<round> \
--role developer \
--profile-id codex-dev-standard \
--worktree <allowed-worktree>
```
确认后执行 `launch`,并显式绑定刚审阅的 fingerprint:
```bash
python3 <ack-skill-dir>/scripts/launch_worker.py launch \
--project-root <project-root> \
--task-id <task-id> \
--attempt-id <task-id>-A<round> \
--role developer \
--profile-id codex-dev-standard \
--worktree <allowed-worktree> \
--expected-launch-fingerprint <plan 中的 sha256:...>
```
launcher 是自动创建 worker 的唯一入口:它从权威任务板重读 profile,构造固定
argv,忽略调用者 PATH、使用环境 allowlist,并验证真实 Git worktree 注册;然后
通过仓库外的单次启动记录、terminal-bound nonce/proof 和受限 bootstrap 调用 Orca。
返回的 receipt 含 `runtimeId`、handle、incarnation、profile hash、slot 和 worktree
identity。Coordinator 将 receipt 追加到顶层 `workerReceipts`,再把 receipt ID
写入任务的 `dispatch.developer` 或 `dispatch.test`,并同步写入本轮 `attemptId`。
校验器要求 receipt 与当前 ACK task、角色、profile 和 attempt 完全一致;历史 receipt
不能跨任务或跨轮次改挂。
v0.10 自动 launcher 仅支持 `read-only` 与 `workspace-write`。full-access、
Codex bypass、Cursor YOLO/force、Grok `--yolo` / bypassPermissions 和关闭
sandbox 都会 fail closed;在有可信平台审批或独立签发通道之前,不用项目文件
伪装成用户授权。Grok worker 由 launcher 固定带 `--always-approve`,避免工具调用
停在确认框,sandbox 仍必须启用。v0.17 起 `cli: grok` 是一等 worker CLI;`cli: omp` 使用 OMP 的结构化模型、thinking 和 approval-mode 参数,workspace-write 默认 yolo。旧任务板没有结构化
`project.orchestration` 时仍可读取和手动协作,但不得自动创建 worker。
持久化 `receiptHash` 是无密钥 checksum,不是 launcher 身份证明。ACK 只复用同一轮
内明确空闲、角色/profile/worktree 匹配,并且可以可信清理历史消息、取得新会话身份的
worker;正在执行、等待回报、状态不明或关联未完成任务的 worker 不复用。由于当前
Orca 没有可验证的历史消息清理接口,Orca 派发仍重新 `plan` 并用 expected fingerprint
启动 fresh worker。
fingerprint 只校验完整计划没有漂移,不是一次性令牌;成功后不得用同一 fingerprint
重复启动,结果不确定时必须先 reconcile。
若创建或关闭回执不完整,或外部 launch record 状态无法可靠持久化,launcher 会返回
`indeterminate/reconcile-required`;必须先核对 record 与 Orca live state,不能
直接重试。
Coordinator 最后标记整轮任务完成后,会关闭所有只关联 `verified` 任务的 worker
终端。仍关联 `blocked`、`failed_retest`、`leftover`、其它未完成任务或未解决环境事件
的终端继续保留且不设置 TTL;receipt 与测试证据不会随终端删除。
## 开始一个需求
初始化完成后可以直接说:
```text
/ack 处理这个需求:<一句话需求>
```
Coordinator 会先读取项目状态和 `references/kickoff.md`,生成产品文档、任务拆分与
可观测验收信号;用户确认后才派发实现和复测。任务 `verified` 后会把本轮黑盒路径
收进 `.pouch/ack/regression.yaml`,确认后才写入。
## 修复 bug
可以直接说:
```text
/ack 修这个 bug:<现象、复现、期望>
```
Coordinator 写短问题说明、复现步骤和可观测验收,不写大 PRD;你确认后再派发。
Developer 先补会失败的用例再修,后续闭环与做需求相同。
配了飞书 `bugIntake` 时,飞书是审核前的唯一协作区。你只需维护标题、详细描述和
附件;Coordinator 整理问题说明、期望效果和验收标准并写回飞书。你针对当前
revision 审核通过,并亲自把状态改成「已确认」之前,不创建任务、不派 worker。
```text
/ack 处理飞书里待整理的 bug
```
## 运行回归
平时做需求和修 bug 结束后,ACK 按本轮内容更新 `.pouch/ack/regression.yaml`。
之后可以单独跑:
```text
/ack 回归
/ack 跑 full 回归
/ack 回归 REG-login-001
/ack 修这些回归失败
```
ACK 先按 deployer 布测试环境,再派独立 Test 按用例用浏览器或 API 执行。失败只
报告,不会自动开修;要修再说 `/ack 修这些回归失败`。默认跑 smoke。细则见
`references/regression.md`。
首次配置交付可以说:
首次配发版也可以一次说清:
```text
/ack 更新项目交付配置:用 make build-deb 构建 DEB,发布到 preview APT 仓库,
部署到 test-server 并跑健康检查;完成后创建 PR,停在 review_ready 给我审核。
```
之后处理需求时只需在确认计划中选择 profile:
之后做需求时只要在确认计划里选 profile:
```text
/ack 处理这个需求:<一句话需求>。任务验证通过后执行 review profile。
/ack 处理这个需求:…。任务验证通过后执行 review profile。
```
ACK 会自动读取 `delivery.yaml`,无需再逐步提醒它构建、上传、部署或开 PR;目标或
权限发生漂移、缺少凭据、进入 stable/production approval 时才停下来请求决策。
## Agent 会做什么
1. 初始化或检查 `.pouch/ack/`,缺项按统一格式列出;不从 README/CI 猜测并启用交付或知识。
2. 做需求:先写产品文档、任务拆分和可观测验收;修 bug 只写短说明、复现和验收。你确认后再派 Developer 和独立 Test。
3. 需要测试环境时加载 deployer,不自己编 compose,也不把本地 `npm run dev` 当成测试环境。
4. 任务 `verified` 后提议把本轮黑盒路径写入回归目录,你确认后才写。
5. 发版读同一份 `delivery.yaml`。默认最多停在 `validation_ready` 或 `review_ready`;稳定发布或生产部署必须再经过对应批准。
6. 回归失败只报告,不会自动开修。
7. 未获授权不提交、不推送、不创建终端、不部署,也不改项目的 `AGENTS.md` / `CLAUDE.md`。
飞书收件时,你维护标题、详细描述和附件。Coordinator 整理问题说明、期望和验收并写回飞书。你针对当前 revision 审核通过,并亲自把状态改成「已确认」之前,不创建任务、不派 worker。
## 如何判断完成
- 初始化:五个状态文件齐,报告为完成,或列出待配置项和下一步。
- 需求 / bug:任务变为 `verified`,或三轮后记为 `leftover`;回归目录有本轮用例,或明确「无回归」。
- 测试环境:目标环境容器 Up,健康检查通过。
- 发版:停在约定档位;stable / 生产有单独批准记录。
- 回归:列出通过和失败的用例;失败不会被自动拿去修。
## 版本
当前 Skill 版本见 `VERSION`。新项目在 `tasks.yaml` 中以合法 SemVer 记录
`ackVersion`。从 `0.10.0` 起,`project.orchestration` 与顶层 `workerReceipts` 必须
同时存在;从 `0.11.0` 起,新项目还会生成默认关闭的 `delivery.yaml`,并在任务板声明
`project.deliveryFile` 与 `deliveryRuns`;从 `0.13.0` 起,Coordinator 使用
`select_tasks.py` 获取有预算的任务上下文,不再把完整任务板注入模型;从 `0.16.0` 起,
`delivery.yaml` 可用 `intents.testEnvironment` 与 `intents.release` 把测试环境部署和
版本发布写成用户可单独触发的操作;从 `0.17.0` 起,结构化 worker 路由支持
`cli: grok`(与 Codex、Cursor 并列);从 `0.18.0` 起支持 OMP 的
`cli: omp` profile(精确 provider/model、thinking 与 approval-mode);从 `0.17.1` 起 Grok worker argv 固定带
`--always-approve`,sandbox 仍必开;从 `0.19.0` 起 `intents.testEnvironment` 改为
deployer 绑定,ACK 内部调用 deployer skill 布测试环境,并增加
`.pouch/ack/regression.yaml` 与「运行回归」模式。旧的测试环境 profile ID 字符串不再
执行,需要迁到 `{via: deployer, env: <env>}`。旧项目可以不迁移回归目录而继续使用
原闭环。旧项目的 `kitVersion` 可以继续读取,但建议迁移为 `ackVersion`。
当前 Skill 版本见 `VERSION`。新项目在 `tasks.yaml` 里记 `ackVersion`。
从 0.19.0 起,测试环境写成 `{via: deployer, env: <env>}`,回归目录是 `.pouch/ack/regression.yaml`。旧的测试环境 profile ID 字符串不再执行,需要迁过去。旧项目可以不迁回归目录,继续用原来的闭环。旧的 `kitVersion` 仍可读,建议改成 `ackVersion`。
+105 -12
View File
@@ -1,19 +1,37 @@
# builder
按统一契约完成项目的 DEB 包与 Docker 镜像构建和发布。规范本体见
按统一契约完成项目的 DEB 包、Docker 镜像、Android APK / iOS IPA 构建和发布,
或把产物拷到目录 / 容器。规范本体见
[references/contract.md](references/contract.md),`scripts/check.py` 是契约的
可执行校验器。
## 什么时候使用
- "用 builder 初始化这个项目"
- "帮我构建这个项目的 DEB / Docker 镜像"
- "把 1.2.3 发布到包仓库 / 镜像仓库"
- "帮我构建这个项目的 DEB / Docker 镜像 / APK"
- "把 1.2.3 发布到包仓库 / 镜像仓库 / File Browser"
- "检查这个项目的 makefile.builder 是否符合 builder 契约"
- "看看项目现在的发布流程"
只构建不上传时明确说明即可;上传永远需要你显式授权。
## 安装
```bash
pouch add builder # 当前项目
pouch add builder -g # 全局
```
不要 `pouch init builder`。对新项目说「用 builder 初始化」。
## 使用前准备
- Python 3 和 `make`。
- DEB 轨道需要 `dpkg-deb`;Docker 轨道需要 Docker 和 buildx。
- 发布时在项目根 `.env.builder` 填写仓库地址和 token(不要写进 `.env`,也不要进 git)。
- 正式版本号由 [manage-release](../manage-release/README.md) 打 annotated tag;builder 只读当前 HEAD。
- 脏工作树默认拒绝发布。
## 项目接入契约
对新项目说「用 builder 初始化」。Agent 会探测轨道、按
@@ -21,8 +39,9 @@
不改用户已有的 `Makefile` 或 `.env`。
1. `makefile.builder` 目标:`help/build/clean/version` + 条件 `deb/docker/push*`,
变量 `ARCH/VERSION/DIST_DIR/PROJECT_NAME`。
变量 `ARCHES/ARCH/VERSION/DIST_DIR/PROJECT_NAME`。
`VERSION` 通过 `include <builder>/scripts/version.mk` 从 Git 推导。
多架构清单是 `ARCHES`,见下方「多架构」。
2. 运行 `python3 -I -S <builder>/scripts/check.py . --ready` 直到构建项 PASS。
缺发布键只挡住上传,不挡住构建。
3. 在项目根 `.env.builder` 配置发布环境变量(不要写进 `.env`):
@@ -32,11 +51,58 @@ DEB_SERVER_URL=https://deb.example.com
DEB_REPOSITORY=main
DEB_TOKEN=<token> # 只放 .env.builder 或密钥系统,不进 git
DOCKER_REGISTRY=registry.example.com
COPY_DEST=file-server:/srv
COPY_AS=latest.apk
```
4. 日常发布就是两条命令:
`make -f makefile.builder deb && make -f makefile.builder push-deb`、
`make -f makefile.builder push-docker`。
写了 `ARCHES=amd64 arm64` 时,这两条会打出并上传两个 DEB;镜像一次推多平台。
## 多架构
项目要发哪些 CPU 架构,写在**已提交的** `makefile.builder` 里,不要写进
`.env.builder`(那是密钥,换机器会丢)。
```makefile
ARCHES ?= amd64 arm64
```
`ARCH` 是「这一次」:默认等于清单第一项。不传 `ARCH=` 时,`deb` / `push-deb`
按清单逐个做完;`push-docker` 把清单映射成 `linux/amd64,linux/arm64`。
只做其中一个:`make -f makefile.builder deb ARCH=arm64`。
合法值只有 `amd64` 和 `arm64`(空格分隔)。不要默认写成双架构:本机编不出
另一边时,只留一项。
```bash
make -s -f makefile.builder arches # 打印清单,例如 amd64 arm64
make -f makefile.builder deb # 打出两个 .deb
make -f makefile.builder push-deb # 两个都上传(需你授权发布)
make -f makefile.builder push-docker # 一次 buildx 推两个平台
```
产物:
```text
dist/<name>_<version>_amd64.deb
dist/<name>_<version>_arm64.deb
```
`build` 配方必须用 `$(ARCH)`,否则两个包内容会一样。初始化时 Agent 按仓库里
已有命令来写(Go 一般是 `GOOS=linux GOARCH=$(ARCH) go build …`),不另做一套
运行时探测脚本。看不出交叉编法就不要写双架构。
`packaging/DEBIAN/control` 的 `Architecture:` 必须随 `$(ARCH)` 变,不要写死
`amd64`。模板会先拷到 `dist/packaging-$(ARCH)` 再改这一行。
本机临时只推一个平台,可以在 `.env.builder` 设 `PLATFORMS=linux/amd64`,这是
覆盖不是清单。项目清单始终以 `ARCHES` 为准。
存量 `makefile.builder` 没有 `ARCHES` 时行为与以前相同(默认 `amd64`,
`ARCH=arm64` 仍合法)。要改成双架构:加上面那一行,确认 `build` 吃 `$(ARCH)`,
再跑 `python3 -I -S <builder>/scripts/check.py . --ready`。
## 版本号
@@ -67,20 +133,26 @@ Docker registry/ns/foo:1.4.2
### 测试(其它任何 commit)
基线 = 祖先上最近的那颗稳定 tag(没有则为 `0.0.0`),再加上清洗后的
分支名、相对距离、短 SHA。测试与正式进**同一个** apt / Docker 仓库,
所以 DEB 必须用 `~`,保证测试包不会 `apt upgrade` 盖住正式包。
基线 = 祖先上最近的那颗稳定 tag(没有则为 `0.0.0`)。有基线时测试号把
patch 机械 +1,再加构建日 `YYYYMMDD`、清洗后的分支名、相对距离、短 SHA。
这是排序键,不是在选定下一正式 SemVer。测试与正式进**同一个** apt /
Docker 仓库,所以 DEB 必须用 `~` 挂在下一 patch 上:`1.4.2` <
`1.4.3~20260908…` < `1.4.3`。已装上一正式版的机器可以升到 bugfix 测试包;
同号正式版仍盖住测试。
分支 `feat/login-v2`,相对 `v1.4.2` 第 7 个 commit,SHA `abc1234`:
分支 `feat/login-v2`,相对 `v1.4.2` 第 7 个 commit,SHA `abc1234`,
构建日 `20260908`:
```text
规范 / DEB 1.4.2~feat-login-v2.7+gabc1234
文件名 foo_1.4.2~feat-login-v2.7+gabc1234_amd64.deb
Docker registry/ns/foo:1.4.2-feat-login-v2.7.gabc1234
规范 / DEB 1.4.3~20260908.feat-login-v2.7+gabc1234
文件名 foo_1.4.3~20260908.feat-login-v2.7+gabc1234_amd64.deb
Docker registry/ns/foo:1.4.3-20260908.feat-login-v2.7.gabc1234
```
Docker tag 由规范版本映射:`~` → `-`,`+g` → `.g`(Docker 不允许 `~`)。
安装测试包必须显式指定版本或完整 tag,不能靠无参 `apt upgrade`。
无参 `apt upgrade` 会从上一正式版升到下一 patch 的测试包,但不会盖住同号
正式包。生产若不能自动吃到测试包,应拆仓库/组件。构建日取本机当前日期;
设了 `SOURCE_DATE_EPOCH` 则用该时刻的 UTC 日期。
### 分支名清洗
@@ -96,8 +168,26 @@ detached HEAD 用 `detached`,CI 可注入 `BUILD_BRANCH` / `CI_COMMIT_BRANCH`
用 builder 构建当前版本的 DEB 和镜像,先不要上传。
用 builder 把当前 commit 的产物发布到项目已配置的仓库。
用 builder 发布多平台 linux/amd64,linux/arm64 镜像。
用 builder 把当前版本的 amd64 和 arm64 DEB 打出来并发布。
用 builder 打一份测试 APK 并拷到 File Browser。
```
## Agent 会做什么
1. 初始化:探测 DEB / Docker / Android / iOS / copy 轨道,写出 `makefile.builder` 和 `.env.builder` 骨架,按仓库证据填 `ARCHES`(不默认双架构),不改你已有的 `Makefile` 或 `.env`。
2. 检查:只跑 `scripts/check.py --ready`,不改文件。
3. 构建:走 `make -f makefile.builder`;只获构建授权时不走会自动上传的目标。
4. 发布:先校验产物,再上传。缺发布键、未授权、或可能覆盖同版本时停下问你。
Docker 只有你点名镜像时才走。token 不会写进输出、提交或回复。
## 如何判断完成
- 初始化:`check.py --ready` 的构建项 PASS。只缺发布键算部分完成,仍可构建、不能上传。
- 构建:产物路径、规范版本、架构与 `make -f makefile.builder version` 一致。
- 发布:HTTP 成功或远端 digest 对得上;回复里有包名或镜像引用、SHA-256 / digest、源 commit。
- 测试包不会通过无参 `apt upgrade` 盖住同号正式包;会作为下一 patch 候选盖住上一正式包。
## 脚本一览
| 脚本 | 用途 |
@@ -110,6 +200,9 @@ detached HEAD 用 `detached`,CI 可注入 `BUILD_BRANCH` / `CI_COMMIT_BRANCH`
| `scripts/upload_deb.sh` | 上传 `.deb` 到 HTTP 包仓库(multipart package/token/repository_name) |
| `scripts/publish_docker.sh` | buildx 构建 + 推送镜像,远端 digest 验证 |
| `scripts/verify_deb.sh` | 核对包元数据、内容与 SHA-256 |
| `scripts/copy_artifact.sh` | 把产物拷到 `COPY_DEST`(本地目录或 `container:/dir`) |
| `templates/makefile.android.builder` | Android APK + copy 轨道骨架 |
| `templates/makefile.ios.builder` | iOS IPA + copy 轨道骨架 |
环境变量契约、脚本解析顺序(`$BUILDER_SKILL_DIR` → `~/.pouch/skills/builder/scripts/`)、
脏工作树策略等完整规则见 contract.md。
+14 -11
View File
@@ -1,12 +1,13 @@
---
name: builder
description: >-
初始化或检查 makefile.builder 契约,再构建或发布 deb/镜像。触发词:初始化
builder、检查 makefile.builder、构建/发布 deb、推送 apt、构建/发布 Docker
镜像、make push。未授权不上传。Docker 仅用户点名镜像时才走。
初始化或检查 makefile.builder 契约,再构建或发布 deb/镜像/Android APK/iOS IPA,
或把产物拷到目录/容器。触发词:初始化 builder、检查 makefile.builder、构建/发布
deb、推送 apt、构建/发布 Docker 镜像、打 APK/IPA、拷到 File Browser、make push。
未授权不上传。Docker 仅用户点名镜像时才走。
---
# Builder:DEB / Docker 构建发布
# Builder:DEB / Docker / Android / iOS 构建发布
**make 管构建,skill 脚本管发布,本 SKILL.md 只留脚本做不了的决策。**
项目状态是根目录 `makefile.builder` 与发布用 `.env.builder`,不要创建
@@ -20,15 +21,15 @@ description: >-
- 初始化、接入 builder,或还没有 `makefile.builder`:执行「初始化」。
- 检查契约或发布配置:执行「检查」。
- 构建、发布、上传:执行「工作流」。不要静默初始化。
- 不适用:本地安装/卸载 DEB;RPM/APK/语言包;从零设计打包体系(先出方案);普通编码与 Dockerfile 编辑。
- 不适用:本地安装/卸载 DEB;RPM/语言包;从零设计打包体系(先出方案);普通编码与 Dockerfile 编辑。
- 改本 skill 自身:契约先改 `scripts/check.py`,再同步 [contract.md](references/contract.md) 与 templates;不在生产上传上试脚本。
## 初始化
1. 确认项目根。探测 `makefile.builder`、`.env.builder`、用户 Makefile(只当抄配方的证据,不改)、`Dockerfile`、`debian/`。`.env.builder` 只看键是否存在且非空,不读、不打印值。不要读取用户 `.env`。
2. 判定轨道:有 Dockerfile → docker;有 deb 信号或用户要打 deb → deb;都不清则问。不要猜测 registry、token 或仓库名。
3. 没有 `makefile.builder`:把 `<skill-dir>/templates/makefile.builder` 拷到项目根。按轨道删掉未使用的 deb/docker/push* 段,把 `build` 的 TODO 换成仓库里已有的真实编译命令(可从用户 Makefile 抄配方,但不要 `include` 或递归调用它)。`include` builder 的 `scripts/version.mk`。双产物把 `push` 改成 `push: push-deb push-docker`。不要改用户 Makefile。
4. 已有 `makefile.builder`:跑检查;按 FAIL 给出修补说明。不覆盖该文件,除非用户明确要求按契约改。不要调用 create-makefile(版本规则冲突)。
2. 判定轨道:有 Dockerfile → docker;有 deb 信号或用户要打 deb → deb;`app.config.ts` / `assembleRelease` / `.apk` → android;`ios/` / `xcodebuild` / `.ipa` → ios;用户要拷到目录或容器 → copy。可多轨道。都不清则问。不要猜测 registry、token、COPY_DEST 或仓库名。
3. 没有 `makefile.builder`:按轨道拷模板。deb/docker 用 `templates/makefile.builder`;仅 android 用 `templates/makefile.android.builder`;仅 ios 用 `templates/makefile.ios.builder`。按轨道删掉未使用的 deb/docker/push* 段,把 `build` 的 TODO 换成仓库里已有的真实编译命令(可从用户 Makefile 抄配方,但不要 `include` 或递归调用它),且配方必须使用 `$(ARCH)`。Android/iOS 的 `ARCHES` 是构建机 ISA(fat APK/IPA 仍只留本机一项)。按 [contract.md](references/contract.md) 填 `ARCHES`:`debian/control` / CI matrix / 用户点名的子集;否则只写本机(`x86_64`→`amd64`,`aarch64`→`arm64`)。不要默认双架构;看不出交叉编法时只留一项并说明。`include` builder 的 `scripts/version.mk`。多产物把 `push` 改成 `push: push-deb push-docker push-copy` 的子集。不要改用户 Makefile。
4. 已有 `makefile.builder`:跑检查;按 FAIL 给出修补说明。不覆盖 `build`。用户明确要求按契约改时再加 `ARCHES`,并确认配方里有 `$(ARCH)`。不要调用 create-makefile(版本规则冲突)。
5. 没有 `.env.builder`:把 `<skill-dir>/templates/env.builder` 拷到项目根(注释键,不含值)。缺发布键时在报告里给出可粘贴示例,并把 `.env.builder` 加入 `.gitignore`,不要提交。
6. 运行 `python3 -I -S <skill-dir>/scripts/check.py <project-dir> --ready`。结构校验通过且当前轨道能构建才称「完成」;只缺发布键是「部分完成」。契约 FAIL 或轨道工具缺失是「阻塞」。除非用户明确要求,不提交、不推送、不上传。
@@ -47,6 +48,8 @@ DEB_SERVER_URL=https://deb.example.com
DEB_REPOSITORY=main
DEB_TOKEN=
DOCKER_REGISTRY=registry.example.com
COPY_DEST=file-server:/srv
COPY_AS=latest.apk
```
## 检查
@@ -59,7 +62,7 @@ DOCKER_REGISTRY=registry.example.com
1. 跑 `check.py <project-dir>`;需要工具链与发布键时加 `--ready`;要实构 deb 时加 `--build`。契约 FAIL 或轨道工具缺失:停下,转入「初始化」,不要绕过校验,不要改用户 Makefile。只缺发布键:允许构建,禁止上传。未接契约的存量项目按 contract.md §6 发现已有入口,上传仍用 builder 脚本;成功交付一次后引导迁到 `makefile.builder`。
2. 仅当用户明确要求发布、上传或提交时才上传。不猜测生产端点;不把 token 写入输出、文件、提交或回复;不用 `set -x` 跑含凭据的命令。可能覆盖同版本时先问。脏工作树默认拒绝发布;用户明确接受时设 `ALLOW_UNCOMMITTED=1` 并注明未提交修改。
3. 构建:`make -f makefile.builder build ARCH=<amd64|arm64> VERSION=<version>`;DEB 再 `make -f makefile.builder deb ARCH=<...>`。版本按契约从 Git 祖先稳定 tag 推导,不要调用 manage-release,不要用全仓库最新 tag。仅获构建授权时不要走会自动上传的目标。脚本含 `rm -rf` 时先确认为受限构建目录。
4. DEB 上传前:`verify_deb.sh <exact-package-path.deb>`。多个包时不凭文件时间猜测。镜像轨道由 `publish_docker.sh` 自带远端 inspect。
5. 发布优先 `make -f makefile.builder push[-deb|-docker]`,或直接调 `upload_deb.sh` / `publish_docker.sh`。脚本缺环境变量时加载 `.env.builder`(shell 显式值优先),不读 `.env`。不把 token 当命令行参数;不把脚本复制进项目。多平台镜像只能走 `publish_docker.sh`,不能拆进 make。
3. 构建:用户指定了架构才传 `ARCH=`;否则不要传,让 makefile 按 `ARCHES` 展开(`make -s -f makefile.builder arches`)。`make -f makefile.builder build` 始终单架构;DEB 用 `make -f makefile.builder deb`。版本按契约从 Git 祖先稳定 tag 推导,不要调用 manage-release,不要用全仓库最新 tag。仅获构建授权时不要走会自动上传的目标。脚本含 `rm -rf` 时先确认为受限构建目录。
4. DEB 上传前:对每个包 `verify_deb.sh <exact-package-path.deb> <version> <arch>`。多个包时不凭文件时间猜测。镜像轨道由 `publish_docker.sh` 自带远端 inspect。
5. 发布优先 `make -f makefile.builder push[-deb|-docker|-copy]`,或直接调 `upload_deb.sh` / `publish_docker.sh` / `copy_artifact.sh`。未指定 `ARCH=` 时 `push-deb` 上传清单里每一个 `.deb`。拷贝轨道要 `COPY_DEST`(本地目录或 `container:/dir`);`COPY_AS` 给第一份文件额外名字。脚本缺环境变量时加载 `.env.builder`(shell 显式值优先),不读 `.env`。不把 token 当命令行参数;不把脚本复制进项目。多平台镜像只能走 `publish_docker.sh`,不能拆进 make;不要把项目架构清单写进 `.env.builder`。`build` 里禁止 `docker cp`。
6. 发布成功不能只看「curl/push 已执行」。要有退出码、HTTP 成功或远端 digest;索引异步时报告「上传已接受,索引尚待更新」。最终回复给出包名/镜像引用、版本、架构、SHA-256 或 digest、源 commit、工作区状态和未完成项。
+53 -19
View File
@@ -28,44 +28,62 @@ make -f makefile.builder <target>
| `help` | 分组列出全部目标;首屏含当前版本 |
| `version` | 输出一行版本号,适合脚本消费 |
| `clean` | 只删除明确、受限的构建产物目录 |
| `build` | 编译/打包主产物;尊重 `ARCH`;**不得内含任何上传动作** |
| `build` | 编译/打包主产物;尊重 `ARCH`(单架构原语);**不得内含任何上传动作** |
### 条件目标
| 目标 | 适用 | 要求 |
|------|------|------|
| `deb` | 有 DEB 产物的项目 | 产出唯一 `$(DIST_DIR)/<name>_<version>_<arch>.deb`;只构建不上传 |
| `deb` | 有 DEB 产物的项目 | 产出 `$(DIST_DIR)/<name>_<version>_<arch>.deb`;只构建不上传。未在命令行/环境指定 `ARCH=` 且 `ARCHES` 多于一项时,按清单逐个递归构建;指定了 `ARCH=` 则只打那一个 |
| `docker` | 有镜像的项目 | 构建本地单平台镜像 `linux/$(ARCH)`;**禁止 `--push`、禁止多平台** |
| `push-deb` | 同时有 DEB 和镜像的项目 | 仅调 builder 的 `upload_deb.sh` 上传 `dist/*.deb` |
| `push-docker` | 同时有 DEB 和镜像的项目 | 仅调 builder 的 `publish_docker.sh` |
| `push` | 单一产物类型时必备;双产物项目为聚合 | 依序调用对应 push-* 或直接调脚本;是发布的唯一 make 入口 |
| `android` | 有 Android APK/AAB 的项目 | 可与 `build` 相同。产出 `$(DIST_DIR)/<name>_<version>_<arch>.apk`(或 `.aab`);只构建不上传。`ARCHES` 是构建机 ISA;fat APK 只留本机一项 |
| `ios` | 有 iOS IPA 的项目 | 可与 `build` 相同。产出 `$(DIST_DIR)/<name>_<version>_<arch>.ipa`;只构建不上传 |
| `arches` | 可选(新模板必备) | 打印一行空格分隔的 `ARCHES`;无此目标视为存量 makefile,隐式单架构 |
| `push-deb` | 同时有多种发布轨道的项目 | 仅调 builder 的 `upload_deb.sh`。未指定 `ARCH=` 时上传 `ARCHES` 中每一项对应的 `.deb`;指定了则只上传那一个 |
| `push-docker` | 同时有多种发布轨道的项目 | 仅调 builder 的 `publish_docker.sh` |
| `push-copy` | 同时有多种发布轨道的项目 | 仅调 builder 的 `copy_artifact.sh` |
| `push` | 单一产物类型时必备;多产物项目为聚合 | 依序调用对应 push-* 或直接调脚本;是发布的唯一 make 入口 |
规则:
1. 项目有 DEB 产物的判据:`makefile.builder` 配方引用 `dpkg-deb`/`debuild` 或产出 `.deb`。
有镜像的判据:项目根存在 `Dockerfile`。
2. 双产物项目必须拆 `push-deb`/`push-docker`,`push` 依序聚合两者;单产物项目一个
`push` 即可。
Android 判据:配方产出 `.apk` / `.aab`。iOS 判据:配方产出 `.ipa`。
拷贝发布判据:配方调用 `copy_artifact.sh`。
2. 多于一种发布轨道必须拆 `push-deb` / `push-docker` / `push-copy`,`push` 依序聚合;
单轨道项目一个 `push` 即可。
3. `docker` 目标只能本地构建。多平台镜像无法拆成"make 构建 + 单独推送"
(`buildx --push` 是一步),因此多平台发布只能走 `publish_docker.sh`。
4. push 类目标必须是薄包装:解析脚本路径后委托,不内联 curl/token/端点。
未指定 `ARCH=` / `PLATFORMS` 时,`publish_docker.sh` 把 `ARCHES` 映射为
`linux/<arch>,…`。
4. push 类目标必须是薄包装:解析脚本路径后委托,不内联 curl/token/端点/`docker cp`。
5. `build` 保持单架构,供 `deb` 循环调用。不要让 `build` 自己遍历 `ARCHES`。
`build` 禁止 `docker cp`、`copy_artifact.sh`、curl/scp。
## 2. 变量
| 变量 | 默认 | 说明 |
|------|------|------|
| `ARCH` | `amd64` | 仅允许 `amd64` \| `arm64`,非法值必须 `$(error)` 报错并提示合法值 |
| `ARCHES` | `amd64` | 本项目要发布的架构清单,空格分隔,子集只能是 `amd64` `arm64`。写在 `makefile.builder` 里并提交;不要放进 `.env.builder`。缺省或无 `arches` 目标时视为单架构 `amd64`。不要默认写成双架构 |
| `ARCH` | `$(firstword $(ARCHES))` | 这一次构建的架构。必须落在 `ARCHES` 里;非法值必须 `$(error)` 并提示合法值。命令行/环境传入时只构建/上传这一项 |
| `VERSION` | `` (空) | 为空时按「版本号」节从 Git 祖先稳定 tag 推导;禁止 `sort -V` 取全局最新 |
| `DIST_DIR` | `dist` | DEB 产物目录 |
| `DIST_DIR` | `dist` | 产物目录(deb / apk / ipa) |
| `PROJECT_NAME` | git 仓库名 | 包名/镜像名主体 |
`ARCHES` 是「这个项目发哪些架构」;`ARCH` 是「这一次编哪个」。宇宙集合仍是 `amd64` \| `arm64`,不开放其它 ISA。
`deb` 配方在打包前必须把 `packaging/DEBIAN/control`(或等价文件)的 `Architecture:` 写成当前 `$(ARCH)`,不要写死 `amd64`。推荐先拷到 `$(DIST_DIR)/packaging-$(ARCH)` 再改,避免弄脏源树。
`build` 配方必须使用 `$(ARCH)`(例如 `GOARCH=$(ARCH)`、`--platform=linux/$(ARCH)`)。配方若忽略 `ARCH`,双架构会打出两个文件名不同、内容却都是宿主机架构的包。Builder 不安装 qemu 或交叉编译器;看不出交叉编法时 `ARCHES` 只留本机一项。
### 版本号
`make -f makefile.builder version` 输出一行规范版本(无 `v` 前缀)。DEB 的
`Version` 与文件名直接用它;Docker tag 由它渲染。推导入口是 `scripts/version.sh`
(`makefile.builder` 通过 `scripts/version.mk` 引用);Builder 只读取 Git 状态,不调用
manage-release,不猜测下一个正式 SemVer。正式 tag 由 manage-release 事先打好。
`version.sh` 不执行 `git fetch`。
manage-release 选定下一正式 SemVer。测试号把祖先基线的 patch 机械 +1 作为排序键
(`v1.4.2` 之后 → `1.4.3~…`),不是在宣布下一正式号就是 1.4.3。正式 tag 由
manage-release 事先打好。`version.sh` 不执行 `git fetch`。
推导前 `git fetch --tags`(本地 linked worktree 共享 tags,不必再 fetch 才
能看见其它 worktree 新打的 tag)。基线是 **HEAD 祖先上最近的稳定 tag**,
@@ -74,17 +92,22 @@ manage-release,不猜测下一个正式 SemVer。正式 tag 由 manage-release
| 判定 | 规范版本 | DEB 文件 | Docker tag |
| --- | --- | --- | --- |
| HEAD exact-match 稳定 tag `v1.4.2` | `1.4.2` | `name_1.4.2_<arch>.deb` | `1.4.2` |
| 其它 commit;祖先最近稳定 tag `v1.4.2` | `1.4.2~<branch>.<n>+g<sha>` | `name_1.4.2~<branch>.<n>+g<sha>_<arch>.deb` | `1.4.2-<branch>.<n>.g<sha>` |
| 祖先中没有稳定 tag | `0.0.0~<branch>+g<sha>` | 同上替换规范版本 | 同上映射 |
| 其它 commit;祖先最近稳定 tag `v1.4.2` | `1.4.3~<date>.<branch>.<n>+g<sha>` | `name_1.4.3~<date>.<branch>.<n>+g<sha>_<arch>.deb` | `1.4.3-<date>.<branch>.<n>.g<sha>` |
| 祖先中没有稳定 tag | `0.0.0~<date>.<branch>+g<sha>` | 同上替换规范版本 | 同上映射 |
测试与正式进入同一 apt / Docker 仓库。测试 DEB 必须用 `~`,使
`1.4.2~…` < `1.4.2`,`apt upgrade` 不会装上测试包。Docker tag 不得含
`~`、`/`、`:`,由规范版本把 `~` → `-`、`+g` → `.g`。
测试与正式进入同一 apt / Docker 仓库。测试 DEB 必须用 `~` 挂在基线的下一
patch 上:`1.4.2` < `1.4.3~…` < `1.4.3`。已装上一正式版的机器可以升到
bugfix 测试包;同号正式版仍盖住测试。不要写成 `1.4.2~…`(小于已发布的
`1.4.2`),不要写成 `1.4.2+<date>` / `1.4.3-beta`(Debian 里 `-beta` 大于
`1.4.3`)。日期必须写在 `~` 之后。Docker tag 不得含 `~`、`/`、`:`,由规范
版本把 `~` → `-`、`+g` → `.g`。
`<date>`:构建日 `YYYYMMDD`。已设 `SOURCE_DATE_EPOCH` 时用该时刻的 UTC
日期,否则用本机当前日期。正式 `X.Y.Z` 不加日期。
`<branch>`:`git rev-parse --abbrev-ref HEAD`,detached 时用
`BUILD_BRANCH` / `CI_COMMIT_BRANCH` / `GITHUB_REF_NAME`,再没有则
`detached`。清洗:小写;`/`、`_` → `-`;去掉非 `[a-z0-9-]`;压缩连续
`-`;过长截断(给 base、距离、SHA 留位置;Docker tag 上限 128)。
`-`;过长截断(给 base、日期、距离、SHA 留位置;Docker tag 上限 128)。
`<n>` 为基线 tag 到 HEAD 的 commit 数;`<sha>` 为 7 位短哈希。不要把
`--dirty` 写入版本;脏树发布仍走既有门禁。显式 `VERSION=` / `IMAGE_TAG=`
可覆盖推导,但不得把非 exact-match 的 commit 标成正式 `X.Y.Z`。
@@ -107,6 +130,15 @@ include $(HOME)/.pouch/skills/builder/scripts/version.mk
| `DEB_REPOSITORY` | 是 | 目标仓库名 |
| `DEB_UPLOAD_PATH` | 否 | 覆盖默认上传路径 `/api/v2/upload/package` |
### 拷贝轨道
| 变量 | 必填 | 说明 |
|---|---|---|
| `COPY_DEST` | 是 | 本地目录,或 Docker 目标 `container:/absolute/dir`(`docker cp`) |
| `COPY_AS` | 否 | 第一份文件的额外 basename,逗号或空格分隔 |
`copy_artifact.sh FILE [FILE ...]` 把已构建文件拷到 `COPY_DEST`,校验目标存在后打印 SHA-256。不读 `.env`。脏工作树默认拒绝,`ALLOW_UNCOMMITTED=1` 放行。
### Docker 轨道
| 变量 | 必填 | 说明 |
@@ -114,7 +146,9 @@ include $(HOME)/.pouch/skills/builder/scripts/version.mk
| `DOCKER_REGISTRY` | 是 | registry 主机,无 scheme |
| `DOCKER_REPOSITORY` | 否 | 默认取 git 仓库名 |
| `IMAGE_TAG` | 否 | 默认由规范版本渲染:正式为 `X.Y.Z`;测试将 `~` 换成 `-`、`+g` 换成 `.g` |
| `PLATFORMS` | 否 | 默认 `linux/amd64`;多平台如 `linux/amd64,linux/arm64` |
| `PLATFORMS` | 否 | 本机/本轮覆盖。未设置时由 `ARCHES` 映射为 `linux/<arch>,…`;再没有则 `linux/amd64`。不要把项目的架构清单只写在这里(`.env.builder` 不进 git) |
`PLATFORMS` 优先级(高 → 低):用户这轮 `--platform` 或已 export 的 `PLATFORMS` → 命令行 `ARCH=` 映射的 `linux/$(ARCH)`(由 `push-docker` 传入)→ `.env.builder` 的 `PLATFORMS` → `makefile.builder` 的 `ARCHES` → `linux/amd64`。
配置来源优先级:shell 已显式设置的值 > 项目根 `.env.builder` > 失败并询问用户。
`.env.builder` 由 builder 脚本加载(不回显任何值);当前 shell 已设置的值优先。
+2 -2
View File
@@ -7,7 +7,7 @@
| Registry | 必须显式确定,例如 `registry.example.com` |
| Repository | 必须包含项目约定的 namespace;缺省取 git 仓库名 |
| Tag | 必须显式确定;优先使用版本号或 Git SHA |
| Platform | 必须显式确定,例如 `linux/amd64` 或 `linux/amd64,linux/arm64` |
| Platform | 用户这轮显式值,否则 `.env.builder` 的 `PLATFORMS`,否则 `makefile.builder` 的 `ARCHES` 映射为 `linux/<arch>,…`,再否则 `linux/amd64` |
| Dockerfile | 默认 `Dockerfile`,不存在或项目另有约定时明确指定 |
| Context | 默认当前项目根目录 |
@@ -35,7 +35,7 @@
## Tag 策略
- 未显式给出 `--tag` / `IMAGE_TAG` 时,使用契约「版本号」渲染出的 tag
(正式 `1.4.2`,测试 `1.4.2-feat-login.7.gabc1234`),不要退回
(正式 `1.4.2`,测试 `1.4.3-<date>.feat-login.7.gabc1234`),不要退回
`git describe`,不要询问后另选一套。
- 正式产物 tag 与 Git tag `vX.Y.Z` 对应,但镜像 tag **不含** `v`;默认视为不可变。
- `latest`、`stable`、裸分支名等浮动 tag 只有在用户明确要求时才额外发布,
+243 -27
View File
@@ -32,8 +32,8 @@ ARCH_VALUES = ("amd64", "arm64")
REQUIRED_TARGETS = ("help", "version", "clean", "build")
UPLOAD_TOKENS = (
"curl ", "curl\t", "scp ", "rsync ", "aptly ", "reprepro ",
"docker push", "buildx build --push", "buildx --push", "upload_deb.sh",
"publish_docker.sh",
"docker push", "docker cp", "buildx build --push", "buildx --push",
"upload_deb.sh", "publish_docker.sh", "copy_artifact.sh",
)
SECRET_PATTERNS = (
re.compile(r"(TOKEN|PASSWORD|SECRET|API_KEY|PASSWD)[A-Z_]*\s*[:?]?=\s*['\"]?[^\s$({\"']+", re.IGNORECASE),
@@ -41,7 +41,7 @@ SECRET_PATTERNS = (
)
FLOATING_TAGS = (":latest", ":stable")
DEB_SHAPE = re.compile(r"^[^_\s]+_[^_\s]+_[^_\s]+\.deb$")
VALID_SCRIPT_NAMES = ("upload_deb.sh", "publish_docker.sh")
VALID_SCRIPT_NAMES = ("upload_deb.sh", "publish_docker.sh", "copy_artifact.sh")
BUILDER_MAKEFILE = "makefile.builder"
BUILDER_ENV = ".env.builder"
@@ -78,8 +78,9 @@ def run_make(
def has_no_rule(result: subprocess.CompletedProcess[str]) -> bool:
err = result.stderr.lower()
return result.returncode != 0 and (
"No rule to make target" in result.stderr or "no rule to make target" in result.stderr.lower()
"no rule to make target" in err or "没有规则可制作目标" in result.stderr
)
@@ -99,6 +100,33 @@ def clean_make_output(result: subprocess.CompletedProcess[str]) -> list[str]:
return lines
def parse_dry_echo(result: subprocess.CompletedProcess[str]) -> str:
"""Recover `@echo …` payload from `make -n` output."""
out_lines = [
ln.lstrip()[5:]
for ln in result.stdout.splitlines()
if ln.lstrip().startswith("echo ")
]
return "\n".join(out_lines).strip()
def read_arches(project: Path) -> list[str] | None:
"""Return ARCHES if the `arches` target exists.
None means a legacy makefile without the target. An empty list means the
target exists but printed nothing usable.
"""
result = run_make(project, "arches")
if has_no_rule(result):
return None
if result.returncode != 0:
return []
raw = parse_dry_echo(result)
if not raw:
return []
return raw.split()
def check_required_targets(report: Report, project: Path) -> dict[str, bool]:
present: dict[str, bool] = {}
lines = []
@@ -112,25 +140,68 @@ def check_required_targets(report: Report, project: Path) -> dict[str, bool]:
def check_arch_guard(report: Report, project: Path) -> None:
title = "ARCH / ARCHES 守卫与缺省值"
problems: list[str] = []
bad = run_make(project, "build", "ARCH=loongarch")
guard_ok = bad.returncode != 0 and ("amd64" in bad.stderr or "arm64" in bad.stderr)
guard_ok = bad.returncode != 0 and (
"amd64" in bad.stderr or "arm64" in bad.stderr or "ARCH" in bad.stderr
)
default_ok = run_make(project, "build").returncode == 0
lines = [
f"invalid ARCH rejected: {'yes' if guard_ok else 'NO'}",
f"default ARCH works: {'yes' if default_ok else 'no'}",
]
hint = "" if guard_ok else "\n Hint: add `$(error ARCH must be amd64 or arm64)` guarded by an ifneq filter."
if guard_ok and default_ok:
report.add(PASS, 2, "ARCH 守卫与缺省值", "\n".join(lines + hint.splitlines()))
if not guard_ok:
problems.append(
"Hint: reject unknown ARCH with `$(error)` naming allowed values "
"(amd64/arm64, or the project's ARCHES)."
)
if not default_ok:
problems.append("`make -n build` without ARCH= must succeed")
arches_make = run_make(project, "arches")
if has_no_rule(arches_make):
lines.append("arches target: absent (legacy; implicit ARCHES = default ARCH)")
elif arches_make.returncode != 0:
problems.append(
f"`make -n arches` failed: {arches_make.stderr.strip() or 'unknown error'}"
)
else:
report.add(FAIL, 2, "ARCH 守卫与缺省值", "\n".join(lines) + hint)
raw = parse_dry_echo(arches_make)
arches = raw.split()
lines.append(f"ARCHES: {raw!r}")
if not arches:
problems.append("arches target printed an empty list")
if "," in raw:
problems.append("ARCHES must be space-separated, not comma-separated")
unknown = [a for a in arches if a not in ARCH_VALUES]
if unknown:
problems.append(f"ARCHES entries must be amd64 or arm64: {unknown}")
if len(arches) != len(set(arches)):
problems.append("ARCHES must not contain duplicates")
outsider = next((a for a in ARCH_VALUES if a not in arches), None)
if outsider:
extra = run_make(project, "build", f"ARCH={outsider}")
extra_ok = extra.returncode != 0
lines.append(
f"ARCH={outsider} rejected (not in ARCHES): "
f"{'yes' if extra_ok else 'NO'}"
)
if not extra_ok:
problems.append(
f"ARCH={outsider} must fail when it is not listed in ARCHES"
)
if problems:
report.add(FAIL, 2, title, "\n".join(lines + [""] + problems))
else:
report.add(PASS, 2, title, "\n".join(lines))
def check_version_output(report: Report, project: Path) -> None:
result = run_make(project, "version")
# Dry run: the echoed `@echo <version>` line IS the would-be output.
out_lines = [ln.lstrip()[5:] for ln in result.stdout.splitlines() if ln.lstrip().startswith("echo ")]
out = "\n".join(out_lines).strip()
out = parse_dry_echo(result)
single = len(out.splitlines()) == 1 and out != ""
no_v = single and not out.startswith("v")
detail = f"stdout={out!r}"
@@ -185,6 +256,24 @@ def check_deb_recipe(report: Report, project: Path, built_deb: Path | None) -> N
problems.append(f"dpkg-deb --info failed on {built_deb.name}")
else:
problems.append("dpkg-deb unavailable; metadata not verified (--build)")
arches = read_arches(project)
if arches and len(arches) > 1 and dry.returncode == 0:
blob = dry.stdout + dry.stderr
missing = [
a for a in arches if f"ARCH={a}" not in blob and f"_{a}.deb" not in blob
]
if missing:
problems.append(
"ARCHES has multiple values but `make -n deb` does not fan out to: "
+ ", ".join(missing)
)
pinned = arches[-1]
one = run_make(project, "deb", f"ARCH={pinned}")
one_blob = one.stdout + one.stderr
if f"_{pinned}.deb" not in one_blob and f"ARCH={pinned}" not in one_blob:
problems.append(
f"`make -n deb ARCH={pinned}` does not build that single arch"
)
if problems:
report.add(FAIL, 5, "deb 目标产物形状与纯构建", "\n".join(problems))
else:
@@ -196,6 +285,45 @@ def detect_docker_project(project: Path) -> bool:
return (project / "Dockerfile").exists() or (project / "docker-compose.yaml").exists()
def detect_android_project(recipe_all: str) -> bool:
return ".apk" in recipe_all or ".aab" in recipe_all
def detect_ios_project(recipe_all: str) -> bool:
return ".ipa" in recipe_all
def detect_copy_project(recipe_all: str) -> bool:
return "copy_artifact.sh" in recipe_all
def check_mobile_recipe(
report: Report, project: Path, *, kind: str, number: int, ext: str
) -> None:
target = "build"
dry = run_make(project, "build")
text = chr(10).join(clean_make_output(dry))
problems = []
if dry.returncode != 0:
problems.append(f"`make -n {target}` failed: {dry.stderr.strip() or 'unknown error'}")
else:
if "dist/" not in text and "$(DIST_DIR)" not in text:
problems.append("recipe does not reference dist/ ($(DIST_DIR)) as artifact location")
wanted = (".apk", ".aab") if kind == "android" else (ext,)
if not any(item in text for item in wanted):
problems.append(f"recipe does not mention {' or '.join(wanted)}")
hits = [token for token in UPLOAD_TOKENS if token in text]
if hits:
problems.append("recipe contains upload commands: " + ", ".join(hits))
if "rm -rf /" in text or "rm -rf ~" in text:
problems.append("recipe contains unrestricted rm -rf")
title = f"{kind} 目标产物形状与纯构建"
if problems:
report.add(FAIL, number, title, "\n".join(problems))
else:
report.add(PASS, number, title, f"{target} writes {ext} under dist/")
def check_docker_recipe(report: Report, project: Path) -> None:
dry = run_make(project, "docker")
text = chr(10).join(clean_make_output(dry))
@@ -226,23 +354,43 @@ SCRIPT_RESOLVE_SNIPPETS = tuple(
)
def check_push_delegates(report: Report, project: Path, dual_artifact: bool) -> None:
targets = ("push-deb", "push-docker") if dual_artifact else ("push",)
def check_push_delegates(
report: Report,
project: Path,
*,
deb_project: bool,
docker_project: bool,
copy_project: bool,
) -> None:
tracks = []
if deb_project:
tracks.append("deb")
if docker_project:
tracks.append("docker")
if copy_project:
tracks.append("copy")
multi = len(tracks) > 1
targets = tuple(f"push-{t}" for t in tracks) if multi else ("push",)
missing = []
inline = []
thin = []
builder_names = ("upload_deb.sh", "publish_docker.sh", "copy_artifact.sh")
for target in targets:
dry = run_make(project, target)
if has_no_rule(dry):
missing.append(target)
continue
text = chr(10).join(clean_make_output(dry))
bad_tokens = [token for token in ("curl ", "scp ", "aptly ", "reprepro ") if token in text]
bad_tokens = [
token
for token in ("curl ", "scp ", "aptly ", "reprepro ", "docker cp")
if token in text
]
if bad_tokens:
inline.append(f"{target}: inline upload command ({', '.join(bad_tokens)})")
elif not any(snippet in text for snippet in SCRIPT_RESOLVE_SNIPPETS) \
and "$(BUILDER_SCRIPT)" not in text and "upload_deb.sh" not in text \
and "publish_docker.sh" not in text:
and "$(BUILDER_SCRIPT)" not in text \
and not any(name in text for name in builder_names):
inline.append(f"{target}: does not call a builder script (expected $BUILDER_SKILL_DIR/... or ~/.pouch/... path)")
else:
thin.append(target)
@@ -250,6 +398,20 @@ def check_push_delegates(report: Report, project: Path, dual_artifact: bool) ->
if missing:
problems.append("missing targets: " + ", ".join(missing))
problems.extend(inline)
if deb_project:
push_deb_target = "push-deb" if multi else "push"
if push_deb_target not in missing:
arches = read_arches(project)
if arches and len(arches) > 1:
dry = run_make(project, push_deb_target)
if not has_no_rule(dry):
text = chr(10).join(clean_make_output(dry))
absent = [a for a in arches if f"_{a}.deb" not in text]
if absent:
problems.append(
f"ARCHES has multiple values but `make -n {push_deb_target}` "
"does not upload: " + ", ".join(absent)
)
status = PASS if not problems else FAIL
detail = "\n".join(problems) if problems else "thin wrappers: " + ", ".join(thin)
report.add(status, 7, "push 仅委托 builder 脚本(薄包装)", detail)
@@ -280,6 +442,7 @@ def check_secrets_and_tags(report: Report, project: Path) -> None:
DEB_ENV_KEYS = ("DEB_SERVER_URL", "DEB_TOKEN", "DEB_REPOSITORY")
DOCKER_ENV_KEYS = ("DOCKER_REGISTRY",)
COPY_ENV_KEYS = ("COPY_DEST",)
ENV_KEY_LINE = re.compile(r"^([A-Za-z_][A-Za-z0-9_]*)=(.*)$")
@@ -311,7 +474,7 @@ def key_present(key: str, env_keys: set[str]) -> bool:
def check_ready_toolchain(
report: Report, *, deb_project: bool, docker_project: bool
report: Report, *, deb_project: bool, docker_project: bool, copy_project: bool = False
) -> None:
lines = [f"make: {'found' if shutil.which('make') else 'MISSING'}"]
problems = []
@@ -331,6 +494,11 @@ def check_ready_toolchain(
problems.append("install dpkg-dev (or equivalent) to build .deb packages")
else:
lines.append("dpkg-deb: skipped (no deb track)")
if copy_project:
docker_ok = shutil.which("docker") is not None
lines.append(
f"docker (for container COPY_DEST): {'found' if docker_ok else 'optional; needed only for container:/dir dest'}"
)
report.add(
FAIL if problems else PASS,
10,
@@ -340,13 +508,18 @@ def check_ready_toolchain(
def check_ready_env_keys(
report: Report, project: Path, *, deb_project: bool, docker_project: bool
report: Report,
project: Path,
*,
deb_project: bool,
docker_project: bool,
copy_project: bool = False,
) -> None:
env_keys = env_file_keys(project)
lines = []
missing: list[str] = []
if not deb_project and not docker_project:
report.add(SKIP, 11, "发布环境变量键名(不读取值)", "no deb/docker track")
if not deb_project and not docker_project and not copy_project:
report.add(SKIP, 11, "发布环境变量键名(不读取值)", "no deb/docker/copy track")
return
if deb_project:
for key in DEB_ENV_KEYS:
@@ -364,6 +537,14 @@ def check_ready_env_keys(
missing.append(key)
else:
lines.append("DOCKER_*: skipped (no docker track)")
if copy_project:
for key in COPY_ENV_KEYS:
found = key_present(key, env_keys)
lines.append(f"{key}: {'present' if found else 'MISSING'}")
if not found:
missing.append(key)
else:
lines.append("COPY_*: skipped (no copy track)")
if missing:
lines.extend(
[
@@ -453,7 +634,7 @@ def main(argv: list[str] | None = None) -> int:
)
skip_detail = f"(no {BUILDER_MAKEFILE})"
for number, title, detail in (
(2, "ARCH 守卫与缺省值", skip_detail),
(2, "ARCH / ARCHES 守卫与缺省值", skip_detail),
(3, "version 输出一行非空规范版本(无 v 前缀)", skip_detail),
(4, "build 不含上传动作", skip_detail),
(5, "deb 目标产物形状与纯构建", skip_detail),
@@ -480,7 +661,17 @@ def main(argv: list[str] | None = None) -> int:
# Gather every recipe once via dry-running all known targets (best effort).
recipe_all_parts = []
for target in (*REQUIRED_TARGETS, "deb", "docker", "push", "push-deb", "push-docker"):
for target in (
*REQUIRED_TARGETS,
"deb",
"docker",
"android",
"ios",
"push",
"push-deb",
"push-docker",
"push-copy",
):
result = run_make(project, target)
if result.returncode == 0:
recipe_all_parts.append(result.stdout)
@@ -491,13 +682,16 @@ def main(argv: list[str] | None = None) -> int:
built_deb: Path | None = None
deb_project = detect_deb_project(recipe_all, project)
docker_project = detect_docker_project(project)
android_project = detect_android_project(recipe_all)
ios_project = detect_ios_project(recipe_all)
copy_project = detect_copy_project(recipe_all)
if present["build"]:
check_arch_guard(report, project)
check_version_output(report, project)
check_build_has_no_upload(report, project)
else:
report.add(SKIP, 2, "ARCH 守卫与缺省值", "(build target missing)")
report.add(SKIP, 2, "ARCH / ARCHES 守卫与缺省值", "(build target missing)")
report.add(SKIP, 3, "version 输出一行非空规范版本(无 v 前缀)", "(version target missing)")
report.add(SKIP, 4, "build 不含上传动作", "(build target missing)")
@@ -516,16 +710,38 @@ def main(argv: list[str] | None = None) -> int:
else:
report.add(SKIP, 6, "docker 目标为本地单平台构建", "(no Dockerfile)")
dual = deb_project and docker_project
check_push_delegates(report, project, dual)
if android_project:
check_mobile_recipe(report, project, kind="android", number=12, ext=".apk")
else:
report.add(SKIP, 12, "android 目标产物形状与纯构建", "(not an Android project)")
if ios_project:
check_mobile_recipe(report, project, kind="ios", number=13, ext=".ipa")
else:
report.add(SKIP, 13, "ios 目标产物形状与纯构建", "(not an iOS project)")
check_push_delegates(
report,
project,
deb_project=deb_project,
docker_project=docker_project,
copy_project=copy_project,
)
check_secrets_and_tags(report, project)
check_script_paths(report)
if args.ready:
check_ready_toolchain(
report, deb_project=deb_project, docker_project=docker_project
report,
deb_project=deb_project,
docker_project=docker_project,
copy_project=copy_project,
)
check_ready_env_keys(
report, project, deb_project=deb_project, docker_project=docker_project
report,
project,
deb_project=deb_project,
docker_project=docker_project,
copy_project=copy_project,
)
total_fail = report.failures
+152
View File
@@ -0,0 +1,152 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'EOF'
Usage:
COPY_DEST=/path/or/container:/dir \
copy_artifact.sh FILE [FILE ...]
Copy built artifacts to COPY_DEST. Dest is a local directory, or a Docker
copy target `container:/absolute/dir` (docker cp). Does not read `.env`.
Environment (project root .env.builder; shell values win):
COPY_DEST Required. Local dir or container:/dir
COPY_AS Optional. Extra basenames for the first file
(comma or space separated)
ALLOW_UNCOMMITTED=1 Publish despite a dirty working tree
Options:
-d DEST Override COPY_DEST
-a NAMES Override COPY_AS
--dry-run Print the copies without executing
-h Show help
The working tree must be clean to publish; set ALLOW_UNCOMMITTED=1 to override.
EOF
}
project_root=$(git rev-parse --show-toplevel 2>/dev/null || true)
if [[ -n "$project_root" && -f "$project_root/.env.builder" ]]; then
while IFS='=' read -r key value; do
key=${key%%[[:space:]]*}
[[ -z "$key" || "$key" == \#* ]] && continue
if [[ -n "${!key:-}" ]]; then
continue
fi
value=${value%\"}; value=${value#\"}; value=${value%\'}; value=${value#\'}
[[ -z "$value" ]] && continue
printf -v "$key" '%s' "$value"
export "$key"
done < <(grep -v '^[[:space:]]*$' "$project_root/.env.builder")
fi
dest=${COPY_DEST:-}
extra_as=${COPY_AS:-}
dry_run=false
while (($#)); do
case "$1" in
-d) dest=$2; shift 2 ;;
-a) extra_as=$2; shift 2 ;;
--dry-run) dry_run=true; shift ;;
-h|--help) usage; exit 0 ;;
--) shift; break ;;
-*) echo "Error: unknown option: $1" >&2; usage >&2; exit 2 ;;
*) break ;;
esac
done
if [[ -z "$dest" || $# -eq 0 ]]; then
echo "Error: COPY_DEST and at least one file are required." >&2
echo "Set them in the environment or the project root .env.builder." >&2
usage >&2
exit 2
fi
if [[ -n "$project_root" ]] && git -C "$project_root" rev-parse HEAD >/dev/null 2>&1; then
if [[ ${ALLOW_UNCOMMITTED:-0} != 1 ]] && ! git -C "$project_root" diff-index --quiet HEAD -- 2>/dev/null; then
echo "Error: working tree has uncommitted changes; refusing to publish." >&2
echo "Commit first, or set ALLOW_UNCOMMITTED=1 to publish anyway." >&2
exit 3
fi
fi
is_docker_dest() {
local d=$1
[[ "$d" != /* && "$d" != ./* && "$d" != ../* && "$d" != ~* ]] \
&& [[ "$d" != *://* ]] \
&& [[ "$d" =~ ^[A-Za-z0-9][A-Za-z0-9_.-]*:/ ]]
}
docker_container=
docker_dir=
local_dir=
if is_docker_dest "$dest"; then
docker_container=${dest%%:*}
docker_dir=${dest#*:}
if [[ -z "$docker_container" || -z "$docker_dir" || "$docker_dir" != /* ]]; then
echo "Error: docker dest must be container:/absolute/dir" >&2
exit 2
fi
docker_dir=${docker_dir%/}
else
local_dir=${dest%/}
fi
split_as() {
local raw=$1
raw=${raw//,/ }
# shellcheck disable=SC2086
printf '%s\n' $raw
}
copy_one() {
local src=$1 name=$2
if [[ ! -f "$src" ]]; then
echo "Error: not a file: $src" >&2
exit 2
fi
if [[ -n "$docker_container" ]]; then
local target="${docker_container}:${docker_dir}/${name}"
if $dry_run; then
echo "docker cp $src $target"
return
fi
docker cp "$src" "$target"
docker exec "$docker_container" ls -l "${docker_dir}/${name}" >/dev/null
echo "copied $(basename -- "$src") -> $target"
else
local target="${local_dir}/${name}"
if $dry_run; then
echo "cp $src $target"
return
fi
mkdir -p "$local_dir"
cp -a "$src" "$target"
test -f "$target"
echo "copied $(basename -- "$src") -> $target"
fi
}
first=true
success=0
for src in "$@"; do
copy_one "$src" "$(basename -- "$src")"
if $first; then
first=false
if [[ -n "$extra_as" ]]; then
while IFS= read -r alias; do
[[ -z "$alias" ]] && continue
copy_one "$src" "$alias"
done < <(split_as "$extra_as")
fi
fi
if ! $dry_run && command -v sha256sum >/dev/null 2>&1; then
sha256sum -- "$src"
fi
success=$((success + 1))
done
echo "copy_artifact: $success file(s) to $dest"
+26 -2
View File
@@ -17,7 +17,8 @@ usage() {
# DOCKER_REGISTRY Required (or --registry)
# DOCKER_REPOSITORY Optional, default: git repository name (or --repository)
# IMAGE_TAG Optional, default: version.sh --docker (or --tag)
# PLATFORMS Optional, default: linux/amd64 (or --platform)
# PLATFORMS Optional. Else makefile.builder ARCHES mapped to
# linux/<arch>,… ; else linux/amd64 (or --platform)
# DOCKER_DOCKERFILE Optional, default: Dockerfile (--file)
# DOCKER_CONTEXT Optional, default: . (--context)
# DOCKER_BUILDER Optional buildx builder name (--builder)
@@ -53,7 +54,7 @@ fi
registry=${DOCKER_REGISTRY:-}
repository=${DOCKER_REPOSITORY:-$git_repo_name}
tag=${IMAGE_TAG:-}
platform=${PLATFORMS:-linux/amd64}
platform=${PLATFORMS:-}
dockerfile=${DOCKER_DOCKERFILE:-Dockerfile}
build_context=${DOCKER_CONTEXT:-.}
builder=${DOCKER_BUILDER:-}
@@ -126,6 +127,29 @@ if [[ "$tag" == latest && ${ALLOW_LATEST:-0} != 1 && "$mode" == push ]]; then
echo "Set ALLOW_LATEST=1 only when the user explicitly asked for 'latest'." >&2
exit 3
fi
if [[ -z "$platform" ]]; then
if [[ -n "$project_root" && -f "$project_root/makefile.builder" ]]; then
arches=$(make -C "$project_root" -f makefile.builder -s --no-print-directory arches 2>/dev/null || true)
mapped=
for arch in $arches; do
case "$arch" in
amd64|arm64)
if [[ -n "$mapped" ]]; then
mapped="$mapped,linux/$arch"
else
mapped="linux/$arch"
fi
;;
*)
echo "Error: unsupported ARCHES entry from makefile.builder: $arch" >&2
exit 2
;;
esac
done
platform=$mapped
fi
platform=${platform:-linux/amd64}
fi
if [[ "$mode" == load && "$platform" == *,* ]]; then
echo "Error: --load cannot be combined with multiple platforms: $platform" >&2
exit 2
+28 -5
View File
@@ -11,8 +11,11 @@ With --docker, print the Docker tag rendering of that version.
Derivation (builder contract «版本号»):
official HEAD exact-match of vX.Y.Z → X.Y.Z
test nearest ancestor stable tag → X.Y.Z~branch.n+gSHA
no tag no stable tag reachable from HEAD → 0.0.0~branch+gSHA
test nearest ancestor stable tag vX.Y.Z → X.Y.(Z+1)~YYYYMMDD.branch.n+gSHA
no tag no stable tag reachable from HEAD → 0.0.0~YYYYMMDD.branch+gSHA
YYYYMMDD is the build date. SOURCE_DATE_EPOCH, if set, is that instant in
UTC; otherwise the local calendar date. Official X.Y.Z never gets a date.
Stable tags match v<major>.<minor>.<patch> only. Baseline is ancestry, not
the highest version in the repository. This script does not fetch tags.
@@ -59,6 +62,15 @@ is_official_version() {
[[ "$1" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]
}
# Mechanical sort key: next patch of a stable tag / X.Y.Z version.
# v1.2.0 → 1.2.1. Not a claim that the next official release is 1.2.1.
bump_patch() {
local v=$1 major minor patch
v=${v#v}
IFS=. read -r major minor patch <<<"$v"
printf '%s.%s.%s' "$major" "$minor" "$((10#$patch + 1))"
}
# Strip one leading v if present.
strip_v() {
local v=$1
@@ -100,6 +112,16 @@ short_sha() {
gitc rev-parse HEAD | cut -c1-7
}
# Build-day YYYYMMDD. SOURCE_DATE_EPOCH (UTC) wins when set, for reproducible
# test packages; otherwise the machine's current local date.
build_date() {
if [[ -n "${SOURCE_DATE_EPOCH:-}" ]]; then
date -u -d "@${SOURCE_DATE_EPOCH}" +%Y%m%d
else
date +%Y%m%d
fi
}
# Highest stable tag pointing at HEAD, or empty.
official_tag_at_head() {
local tag best=
@@ -135,7 +157,7 @@ nearest_stable_tag() {
}
derive_canonical() {
local tag base n sha branch
local tag base n sha branch ymd
tag=$(official_tag_at_head)
if [[ -n "$tag" ]]; then
printf '%s' "${tag#v}"
@@ -143,13 +165,14 @@ derive_canonical() {
fi
sha=$(short_sha)
branch=$(branch_slug)
ymd=$(build_date)
base=$(nearest_stable_tag)
if [[ -z "$base" ]]; then
printf '0.0.0~%s+g%s' "$branch" "$sha"
printf '0.0.0~%s.%s+g%s' "$ymd" "$branch" "$sha"
return
fi
n=$(gitc rev-list --count "${base}..HEAD")
printf '%s~%s.%s+g%s' "${base#v}" "$branch" "$n" "$sha"
printf '%s~%s.%s.%s+g%s' "$(bump_patch "$base")" "$ymd" "$branch" "$n" "$sha"
}
to_docker() {
+3 -1
View File
@@ -10,4 +10,6 @@
# DOCKER_REGISTRY=
# DOCKER_REPOSITORY=
# IMAGE_TAG=
# PLATFORMS=
# PLATFORMS= # optional override; project ARCHES lives in makefile.builder
# COPY_DEST= # local dir, or docker dest container:/absolute/dir
# COPY_AS= # optional extra basenames for the first file
@@ -0,0 +1,57 @@
# Builder 契约文件。放到项目根,用 `make -f makefile.builder <target>`。
# Android 轨道:build 产出 dist/<name>_<version>_<arch>.apk,push 走 copy_artifact.sh。
# ARCHES 是构建机 ISA;APK 可含多 ABI。不要把这些目标写进用户 Makefile。
ARCHES ?= amd64
ARCH ?= $(firstword $(ARCHES))
ifeq ($(strip $(ARCHES)),)
$(error ARCHES must not be empty)
endif
ifneq ($(filter-out amd64 arm64,$(ARCHES)),)
$(error ARCHES entries must be amd64 or arm64)
endif
ifneq ($(filter $(ARCH),$(ARCHES)),$(ARCH))
$(error ARCH must be one of: $(ARCHES))
endif
PROJECT_NAME ?= $(notdir $(CURDIR))
DIST_DIR ?= dist
ifneq ($(BUILDER_SKILL_DIR),)
include $(BUILDER_SKILL_DIR)/scripts/version.mk
else
include $(HOME)/.pouch/skills/builder/scripts/version.mk
endif
BUILDER_SCRIPTS := $(or $(BUILDER_SKILL_DIR),$(HOME)/.pouch/skills/builder)/scripts
APK_OUT := $(DIST_DIR)/$(PROJECT_NAME)_$(VERSION)_$(ARCH).apk
.PHONY: help version arches clean build android push
help:
@echo "$(PROJECT_NAME) $(VERSION)"
@echo " ARCHES=$(ARCHES) ARCH=$(ARCH)"
@echo " make -f makefile.builder build [ARCH=amd64|arm64] - 构建 Release APK"
@echo " make -f makefile.builder version - 打印规范版本"
@echo " make -f makefile.builder arches - 打印架构清单"
@echo " make -f makefile.builder clean - 删除 $(DIST_DIR)"
@echo " make -f makefile.builder push - copy_artifact.sh 拷到 COPY_DEST"
version:
@echo $(VERSION)
arches:
@echo $(ARCHES)
clean:
rm -rf $(DIST_DIR)
# 替换为项目真实 assemble 命令,且必须使用 $(ARCH)。不得 curl/scp/docker cp。
build:
mkdir -p $(DIST_DIR)
cp app-release.apk $(APK_OUT)
android: build
push:
$(BUILDER_SCRIPTS)/copy_artifact.sh $(APK_OUT)
+43 -8
View File
@@ -2,13 +2,26 @@
# 不要把这些目标写进用户的 Makefile / makefile。
# 初始化时按项目轨道删掉未使用的 deb / docker 段。
# 把 build 的 TODO 换成真实编译命令;禁止在 build / deb / docker 里上传。
# build 必须尊重 $(ARCH)。要发双架构时把 arm64 写进 ARCHES,不要改 .env.builder。
#
# include 路径见 references/contract.md。测试或非标准安装可设 BUILDER_SKILL_DIR。
ARCH ?= amd64
ifneq ($(filter $(ARCH),amd64 arm64),$(ARCH))
$(error ARCH must be amd64 or arm64)
ARCHES ?= amd64
ARCH ?= $(firstword $(ARCHES))
ifeq ($(strip $(ARCHES)),)
$(error ARCHES must not be empty)
endif
ifneq ($(filter-out amd64 arm64,$(ARCHES)),)
$(error ARCHES entries must be amd64 or arm64)
endif
ifneq ($(filter $(ARCH),$(ARCHES)),$(ARCH))
$(error ARCH must be one of: $(ARCHES))
endif
# Fan-out over ARCHES only when ARCH was not set on the CLI/environment.
_ARCH_FROM_FILE := $(filter file default undefined,$(origin ARCH))
_MULTI_ARCH := $(and $(_ARCH_FROM_FILE),$(filter-out 1,$(words $(ARCHES))))
_PUSH_DEB_ARCHES := $(if $(_ARCH_FROM_FILE),$(ARCHES),$(ARCH))
PROJECT_NAME ?= $(notdir $(CURDIR))
DIST_DIR ?= dist
@@ -21,40 +34,62 @@ endif
BUILDER_SCRIPTS := $(or $(BUILDER_SKILL_DIR),$(HOME)/.pouch/skills/builder)/scripts
.PHONY: help version clean build deb docker push push-deb push-docker
.PHONY: help version arches clean build deb docker push push-deb push-docker
help:
@echo "$(PROJECT_NAME) $(VERSION)"
@echo " ARCHES=$(ARCHES) ARCH=$(ARCH)"
@echo " make -f makefile.builder build [ARCH=amd64|arm64] - 构建主产物 / build"
@echo " make -f makefile.builder version - 打印规范版本 / canonical version"
@echo " make -f makefile.builder arches - 打印架构清单 / print ARCHES"
@echo " make -f makefile.builder clean - 删除 $(DIST_DIR)"
@echo " make -f makefile.builder deb - 打 DEB(只构建不上传)"
@echo " make -f makefile.builder deb - 打 DEB(按 ARCHES;只构建不上传)"
@echo " make -f makefile.builder docker - 本地单平台镜像"
@echo " make -f makefile.builder push / push-deb / push-docker"
version:
@echo $(VERSION)
arches:
@echo $(ARCHES)
clean:
rm -rf $(DIST_DIR)
# 替换为项目真实构建命令。不得 curl/scp/docker push。
# 替换为项目真实构建命令,且必须使用 $(ARCH)。不得 curl/scp/docker push。
build:
mkdir -p $(DIST_DIR)
@echo "TODO: build $(PROJECT_NAME) for linux/$(ARCH)"
ifdef _MULTI_ARCH
deb:
$(foreach a,$(ARCHES),$(MAKE) -f makefile.builder --no-print-directory deb ARCH=$(a) &&) true
else
deb: build
mkdir -p $(DIST_DIR)
dpkg-deb --build packaging $(DIST_DIR)/$(PROJECT_NAME)_$(VERSION)_$(ARCH).deb
rm -rf $(DIST_DIR)/packaging-$(ARCH)
cp -a packaging $(DIST_DIR)/packaging-$(ARCH)
sed -e 's/^Architecture:.*/Architecture: $(ARCH)/' \
$(DIST_DIR)/packaging-$(ARCH)/DEBIAN/control \
> $(DIST_DIR)/packaging-$(ARCH)/DEBIAN/control.stamped
mv $(DIST_DIR)/packaging-$(ARCH)/DEBIAN/control.stamped \
$(DIST_DIR)/packaging-$(ARCH)/DEBIAN/control
dpkg-deb --build $(DIST_DIR)/packaging-$(ARCH) $(DIST_DIR)/$(PROJECT_NAME)_$(VERSION)_$(ARCH).deb
endif
docker:
docker build --platform=linux/$(ARCH) -t $(PROJECT_NAME):$(IMAGE_TAG) .
push-deb:
$(BUILDER_SCRIPTS)/upload_deb.sh $(DIST_DIR)/$(PROJECT_NAME)_$(VERSION)_$(ARCH).deb
$(BUILDER_SCRIPTS)/upload_deb.sh $(foreach a,$(_PUSH_DEB_ARCHES),$(DIST_DIR)/$(PROJECT_NAME)_$(VERSION)_$(a).deb)
ifdef _ARCH_FROM_FILE
push-docker:
$(BUILDER_SCRIPTS)/publish_docker.sh
else
push-docker:
PLATFORMS=linux/$(ARCH) $(BUILDER_SCRIPTS)/publish_docker.sh
endif
# 单产物项目只保留一条 push。双产物改为:push: push-deb push-docker
push: push-deb
@@ -0,0 +1,57 @@
# Builder 契约文件。放到项目根,用 `make -f makefile.builder <target>`。
# iOS 轨道:build 产出 dist/<name>_<version>_<arch>.ipa,push 走 copy_artifact.sh。
# ARCHES 是构建机 ISA。不要把这些目标写进用户 Makefile。
ARCHES ?= arm64
ARCH ?= $(firstword $(ARCHES))
ifeq ($(strip $(ARCHES)),)
$(error ARCHES must not be empty)
endif
ifneq ($(filter-out amd64 arm64,$(ARCHES)),)
$(error ARCHES entries must be amd64 or arm64)
endif
ifneq ($(filter $(ARCH),$(ARCHES)),$(ARCH))
$(error ARCH must be one of: $(ARCHES))
endif
PROJECT_NAME ?= $(notdir $(CURDIR))
DIST_DIR ?= dist
ifneq ($(BUILDER_SKILL_DIR),)
include $(BUILDER_SKILL_DIR)/scripts/version.mk
else
include $(HOME)/.pouch/skills/builder/scripts/version.mk
endif
BUILDER_SCRIPTS := $(or $(BUILDER_SKILL_DIR),$(HOME)/.pouch/skills/builder)/scripts
IPA_OUT := $(DIST_DIR)/$(PROJECT_NAME)_$(VERSION)_$(ARCH).ipa
.PHONY: help version arches clean build ios push
help:
@echo "$(PROJECT_NAME) $(VERSION)"
@echo " ARCHES=$(ARCHES) ARCH=$(ARCH)"
@echo " make -f makefile.builder build [ARCH=amd64|arm64] - 构建 IPA"
@echo " make -f makefile.builder version - 打印规范版本"
@echo " make -f makefile.builder arches - 打印架构清单"
@echo " make -f makefile.builder clean - 删除 $(DIST_DIR)"
@echo " make -f makefile.builder push - copy_artifact.sh 拷到 COPY_DEST"
version:
@echo $(VERSION)
arches:
@echo $(ARCHES)
clean:
rm -rf $(DIST_DIR)
# 替换为项目真实 xcodebuild/export 命令,且必须使用 $(ARCH)。不得 curl/scp/docker cp。
build:
mkdir -p $(DIST_DIR)
cp app.ipa $(IPA_OUT)
ios: build
push:
$(BUILDER_SCRIPTS)/copy_artifact.sh $(IPA_OUT)
@@ -0,0 +1,55 @@
# declarative-openspec-loop
你提供「怎样算符合预期」的校验方式,Agent 按 OpenSpec 走 propose → apply → 校验,不通过就分析原因再迭代,直到通过。
## 什么时候使用
- 已经能说清目标,也有办法判断结果对不对,希望 Agent 自动做到通过为止。
- 会说「声明式编程」「按预期循环」「做到校验通过」。
- 不适合:还在选型、还没有校验手段、只想改一小处且自己盯着看。
## 安装
```bash
pouch add declarative-openspec-loop # 当前项目
pouch add declarative-openspec-loop -g # 全局
```
## 使用前准备
- 本机已安装 [openspec](https://openspec.dev/) CLI。
- 当前 Agent 已安装 OpenSpec 的三个子 skill:`openspec-explore`、`openspec-propose`、`openspec-apply-change`。
- 你必须给出校验方式,缺了 Agent 会先问,不会空跑。可用下面任意组合:
- 校验命令,退出码 0 即通过,比如 `make test-one2`
- 预期文档,里面有校验项表
- 清单:日志关键字、输出文件、数值容差
- 自由描述:Agent 会先归纳成可执行检查,向你确认后再跑
## 使用示例
```text
按声明式循环做:让登录失败时返回统一错误码。
校验:跑 pytest tests/test_login.py,全部 green 即通过。最多 3 轮。
```
```text
目标:拟合轮廓与 docs/tmp/expect_desc.md 一致。
校验:make test-one2,且日志覆盖文档「校验项」表全部 8 项。
```
## Agent 会做什么
1. 记下 change 名称和校验方式,写到 `docs/tmp/<change>/expectation.md`。
2. 必要时先探索;然后 propose 出 proposal / design / tasks,每个任务尽量带验收。
3. apply 实现,再跑你给的校验。
4. 失败按原因分级:小 bug 直接修;任务遗漏就补 tasks;方向错了才改设计。默认最多 5 轮(你可改上限)。同一错误连续 2 轮会停下来问你。
5. 通过后给出变更摘要和思路,过程记录留在 `docs/tmp/<change>/`。
不会在没校验方式时开跑,也不会到上限后自己放弃或无限重试。
## 如何判断完成
- 你指定的校验命令退出码 0,或校验项全部满足。
- Agent 给出变更文件列表和思路。
- `docs/tmp/<change>/` 里有 `expectation.md`、每轮 `iteration_*.md` 和校验原始输出。
- 到上限或反复失败时停下,并说明当前进度和未通过项,而不是假装完成。
+18 -7
View File
@@ -22,9 +22,18 @@
- 需要一张「哪台机器跑哪些服务」的清单
- 镜像要进 Kubernetes,走 Argo CD:改 GitOps、开 MR、合并后部署
## 安装
```bash
pouch add deployer # 当前项目
pouch add deployer -g # 全局
```
不要 `pouch init deployer`。对新项目说「用 deployer 初始化」。
## 使用前准备
- Compose:本机 Python 3、`rsync`、`ssh`;目标机器 Docker Compose v2;`~/.ssh/config` 免密(或 agent)
- Compose:本机 Python 3、`ssh`;有 `rsync` 更好,没有则 sync 用 `scp` 兜底;目标机器 Docker Compose v2;`~/.ssh/config` 免密(或 agent)
- 知道每个 Compose 服务的运行时数据放在哪(这些目录不能被同步覆盖)
- Argo CD:能 clone/push GitOps 仓库(SSH 或已登录的 HTTPS),并能开 MR(GitLab `glab` / GitHub `gh` / Gitea `tea`)。本机不必长期放一份 GitOps checkout。
@@ -50,6 +59,7 @@ web1 能出网,直接让它从 https://... 把包拉下来装
# 项目内环境
给这个项目建好 .pouch/deployer,prod 和 test 分别放到两台机器上
把 test 环境重新部署一下
本机测试镜像已经有了,直接重启,别 sync
prod 的 compose 加个 redis,改完发上去
# Argo CD
@@ -62,8 +72,8 @@ GitOps 我已经 clone 在 ../infra-gitops,用那个目录开 MR
1. 读服务/环境目录(及共享的父目录)的 `_config.yaml`,确定目标机器和远程路径;
项目内布局从 `.pouch/deployer/` 自动发现,无需额外配置
2. 用 skill 自带脚本把本地目录同步到远程(rsync,自动排除 `data/`、`_data/`)
3. 在远程执行对应的 `docker compose` 操作(启动 / 重建 / 升级 / 重启)
2. 目标是本机且镜像已在本机 docker:不 sync、不 SSH,在该环境目录直接 `docker compose`
3. 否则用 skill 自带脚本把本地目录同步到远程(优先 rsync,没有则 scp;自动排除 `data/`、`_data/`),再远程 `docker compose`(启动 / 重建 / 升级 / 重启)
4. deb 包安装走独立脚本:scp 上传到暂存目录后远程 apt 安装,失败自动修依赖
5. 同步后查看容器状态和日志确认生效
6. 只针对你指定的那一个服务操作,不会批量动整台机器
@@ -73,13 +83,14 @@ GitOps 我已经 clone 在 ../infra-gitops,用那个目录开 MR
项目内布局下,远程目录名自动带上项目前缀(如 `my-project-prod`),
避免同一台机器上多个项目的同名环境互相覆盖;需要固定名字时在 `_config.yaml` 写 `name:`。
重要边界:同步使用 `--delete`,远程多余的文件会被删除;数据库、证书等运行时数据
必须放在排除目录或远程绝对路径挂载。涉及删除数据卷、清理远程文件的操作会先向你确认。
重要边界:rsync 同步使用 `--delete`,远程多余的文件会被删除;scp 兜底不会删。
数据库、证书等运行时数据必须放在排除目录或远程绝对路径挂载。涉及删除数据卷、
清理远程文件的操作会先向你确认。
## 如何判断完成
- 脚本输出显示同步完成、远程命令执行成功
- `ps` 显示容器 Up、`logs` 无报错;升级后镜像 tag 与配置一致
- 本机:`docker compose ps` 容器 Up。远程:sync 成功且 `remote.py ps` 容器 Up
- `logs` 无报错;升级后镜像 tag 与配置一致
- deb 安装后 `ssh <node> dpkg -l` 能看到目标包,服务能正常启动
- 域名/端口类服务能 curl 通
- Argo CD:给出 MR 链接;合入后 Application Synced,域名/healthz 可访问
+16 -5
View File
@@ -19,10 +19,10 @@ description: >-
- 初始化、接入 deployer,或给新项目建测试/生产环境:执行「初始化」。
- 检查 `.pouch/deployer`、node、compose 是否齐全:执行「检查」。
- 部署、同步、升级、重启、装 deb、新增/下线服务:执行「Compose 操作」。发现不了服务或解析不出 node 时停止,转入「初始化」。不要静默初始化。
- 部署、重新部署、同步、升级、重启、布测试环境、装 deb、新增/下线服务:执行「Compose 操作」。发现不了服务或解析不出 node 时停止,转入「初始化」。不要静默初始化。
- ArgoCD / GitOps / 开 MR 部署:执行「Argo CD」。步骤见 [argocd.md](references/argocd.md)。
- 两者都有且意图不清:先问。
- 不适用:单机 docker、Nomad、常规 `kubectl apply`、构建并推送镜像(走 builder)。
- 不适用:没有 `.pouch/deployer/` 的随手 `docker compose`、Nomad、常规 `kubectl apply`、构建并推送镜像(走 builder)。本机 node 仍走本 skill,镜像已在本机则跳过 sync。
## 边界
@@ -56,7 +56,7 @@ python3 -I -S <skill-dir>/scripts/deploy/check.py --project <project-root>
已具备: …
待配置: 路径 + 字段 + 可粘贴示例 + 缺了会挡住哪步
工具链: ssh / rsync(缺则怎么装,不擅自安装)
工具链: ssh / rsync(无 rsync 时 sync 用 scp 兜底,不擅自安装)
下一步: 一句话
```
@@ -66,11 +66,22 @@ python3 -I -S <skill-dir>/scripts/deploy/check.py --project <project-root>
## 被 ACK 调用
ACK 的「运行测试环境」和回归前布环境会加载本 skill,对 `.pouch/deployer/<env>`(通常是 `test`)按「Compose 操作」执行。ACK 只负责何时布、把访问地址写入 `deliveryRuns`;不要把本 skill 的脚本复制进 ACK。生产环境、Argo CD 合入和节点级批量仍须用户明确要求。
ACK 的「运行测试环境」和回归前布环境会加载本 skill,按「Compose 操作」的默认路径布测试环境。ACK 只负责何时布、把访问地址写入 `deliveryRuns`;不要把本 skill 的脚本复制进 ACK。生产环境、Argo CD 合入和节点级批量仍须用户明确要求。
## Compose 操作
读 [compose.md](references/compose.md)。用 `list.py` 摸底,再按意图对**单个服务**执行 sync/up/recreate/upgrade/restart。解析 `_config.yaml` 见 [config-reference.md](references/config-reference.md)。sync 报错但 ssh 正常时,按 compose.md 的 NAS 兜底处理。每次操作后 `remote.py <svc> ps` / `logs` 验证。
重启 / 重新部署 / 同步 / 布测试环境走默认路径。不要读 compose.md、不要初始化、不要读 Makefile。
1. 在项目根执行:`python3 -I -S <skill-dir>/scripts/deploy/list.py`
2. 只操作**一个**服务:用户点名的 env;「测试」→ `test`,没有 `test` 就用非 `prod` 的那个。未点名 `prod` 不要动它。
3. 不要改 compose tag、不要构建镜像、不要看其它环境。
4. 本机镜像:node 的 ssh `HostName` 是 `127.0.0.1`/`localhost`,且 `docker image inspect` 看得到 compose 里的 tag → **不要** `sync.py` / `remote.py`。进 `.pouch/deployer/<env>/` 执行 `docker compose up -d --force-recreate`,再 `ps`。完。
5. 否则才 sync + remote:`python3 -I -S <skill-dir>/scripts/deploy/sync.py <svc>` 然后 `python3 -I -S <skill-dir>/scripts/deploy/remote.py <svc> recreate`。只要重启:`remote.py <svc> restart`。
6. 远程路径验证:`remote.py <svc> ps`;异常再 `logs`。完。
compose 用 `image:` 且用户说刚合并了代码:现有 tag 对不上这次提交时,**一句问**要不要先重建镜像,然后停。不要自己改 tag、不要走 builder。
新增/下线、deb、镜像 tag 升级、NAS 失败:再读 [compose.md](references/compose.md)。字段见 [config-reference.md](references/config-reference.md)。
## Argo CD
+4 -2
View File
@@ -81,6 +81,8 @@ python3 <skill-dir>/scripts/deploy/sync.py <service-path>
| 意图 | 命令 |
|------|------|
| 本机已有镜像(node 是 localhost,image 已在本机 docker) | 不要 sync。在 env 目录 `docker compose up -d --force-recreate` |
| 重新部署远程环境 | `sync.py <svc> && remote.py <svc> recreate` |
| 只同步文件,不动容器 | `sync.py <svc>` |
| 应用 compose/配置变更 | `sync.py <svc> && remote.py <svc> up` |
| 改配置后强制重建 | `remote.py <svc> recreate`(配合前置 sync) |
@@ -88,9 +90,9 @@ python3 <skill-dir>/scripts/deploy/sync.py <service-path>
| 仅重启,不同步文件 | `remote.py <svc> restart` |
| 排查 | `remote.py <svc> ps` / `remote.py <svc> logs` |
若项目有 Makefile 封装,命令形如 `make deploy TGT=<服务路径>`。没有 Makefile 时直接调 python 脚本,不要新建封装层。
脚本一律 `python3 -I -S <skill-dir>/scripts/deploy/<name>.py`。有 `make deploy TGT=` 才用 Makefile;没有就直接调脚本,不要读 Makefile 去发明封装。
`sync.py` 使用 `rsync -avz --delete`;本机无 rsync 时 tar over SSH 兜底。运行时数据必须放在默认排除的 `data/`、`_data/`,或 compose 挂载的远程绝对路径,否则会被清掉。
`sync.py` 使用 `rsync -avz --delete`;本机无 rsync 时 scp 兜底(不 `--delete`)。运行时数据必须放在默认排除的 `data/`、`_data/`,或 compose 挂载的远程绝对路径,否则会被清掉。
## 新增服务 / 环境
+1 -1
View File
@@ -117,7 +117,7 @@ def check_toolchain(report: Report) -> None:
rsync_ok = shutil.which("rsync") is not None
lines = [
f"ssh: {'found' if ssh_ok else 'MISSING (blocks compose deploy)'}",
f"rsync: {'found' if rsync_ok else 'MISSING (tar-over-SSH fallback)'}",
f"rsync: {'found' if rsync_ok else 'MISSING (scp fallback)'}",
]
if not ssh_ok:
lines.append("install openssh-client")
+5 -1
View File
@@ -31,7 +31,11 @@ import subprocess
import sys
from pathlib import Path
from lib import (
_SCRIPT_DIR = Path(__file__).resolve().parent
if str(_SCRIPT_DIR) not in sys.path:
sys.path.insert(0, str(_SCRIPT_DIR))
from lib import ( # noqa: E402
project_root,
run_remote,
scp_base_args,
+6 -1
View File
@@ -6,8 +6,13 @@ from __future__ import annotations
import os
import sys
from collections import defaultdict
from pathlib import Path
from lib import discover_services, project_root
_SCRIPT_DIR = Path(__file__).resolve().parent
if str(_SCRIPT_DIR) not in sys.path:
sys.path.insert(0, str(_SCRIPT_DIR))
from lib import discover_services, project_root # noqa: E402
def main() -> int:
+6 -1
View File
@@ -20,8 +20,13 @@ import argparse
import os
import subprocess
import sys
from pathlib import Path
from lib import project_root, run_remote, service_info
_SCRIPT_DIR = Path(__file__).resolve().parent
if str(_SCRIPT_DIR) not in sys.path:
sys.path.insert(0, str(_SCRIPT_DIR))
from lib import project_root, run_remote, service_info # noqa: E402
REMOTE_COMMANDS = {
"up": "docker compose up -d",
+71 -27
View File
@@ -16,15 +16,20 @@ import shlex
import shutil
import subprocess
import sys
from pathlib import Path
from lib import DEFAULT_BASE_PATH, project_root, rsync_ssh_args, service_info, ssh_cmd
_SCRIPT_DIR = Path(__file__).resolve().parent
if str(_SCRIPT_DIR) not in sys.path:
sys.path.insert(0, str(_SCRIPT_DIR))
from lib import DEFAULT_BASE_PATH, rsync_ssh_args, scp_base_args, service_info, ssh_cmd # noqa: E402
def sync_directory(info: dict) -> int:
if shutil.which("rsync"):
return _sync_rsync(info)
print("本机没有 rsync,改用 tar over SSH(不会删除远程多余文件)")
return _sync_tar(info)
print("本机没有 rsync,改用 scp(不会删除远程多余文件)")
return _sync_scp(info)
def _sync_rsync(info: dict) -> int:
@@ -58,42 +63,81 @@ def _sync_rsync(info: dict) -> int:
print(f"错误: rsync 执行失败,退出码: {e.returncode}")
return 1
except FileNotFoundError:
print("错误: rsync 命令未找到,请确保已安装 rsync")
return 1
print("本机没有 rsync,改用 scp(不会删除远程多余文件)")
return _sync_scp(info)
def _sync_tar(info: dict) -> int:
def collect_sync_files(source_dir: str, excludes: list[str]) -> list[Path]:
"""列出待同步文件;排除规则与 rsync `--exclude=name/` 一致(任意层级的同名目录)。"""
source = Path(source_dir)
exclude_set = set(excludes)
files: list[Path] = []
for root, dirs, names in os.walk(source):
dirs[:] = [d for d in dirs if d not in exclude_set]
for name in names:
path = Path(root) / name
if path.is_file():
files.append(path)
return files
def _sync_scp(info: dict) -> int:
source_dir = info["service_dir"]
remote_dir = info["remote_dir"]
tar_cmd = ["tar", "czf", "-", "-C", source_dir]
for item in info["sync_exclude"]:
tar_cmd.append(f"--exclude={item}")
tar_cmd.append(".")
node = info["node"]
source = Path(source_dir)
files = collect_sync_files(source_dir, info["sync_exclude"])
remote_shell = (
f"mkdir -p {shlex.quote(remote_dir)} && "
f"tar xzf - -C {shlex.quote(remote_dir)}"
)
ssh = ssh_cmd(info, remote_shell)
print(f"正在同步 {source_dir} 到 {info['node']}:{remote_dir}")
print(f"正在同步 {source_dir} 到 {node}:{remote_dir}")
if info["sync_exclude"]:
print(f"排除目录: {', '.join(info['sync_exclude'])}")
print(f"执行命令: tar | {' '.join(ssh)}")
print("-" * 60)
tar = subprocess.Popen(tar_cmd, stdout=subprocess.PIPE)
remote_dirs = {remote_dir}
grouped: dict[str, list[Path]] = {}
for path in files:
parent = path.relative_to(source).parent.as_posix()
grouped.setdefault(parent, []).append(path)
if parent != ".":
remote_dirs.add(f"{remote_dir}/{parent}")
mkdir_remote = "mkdir -p " + " ".join(shlex.quote(d) for d in sorted(remote_dirs))
mkdir_cmd = ssh_cmd(info, mkdir_remote)
print(f"执行命令: {' '.join(mkdir_cmd)}")
try:
completed = subprocess.run(ssh, stdin=tar.stdout, check=False)
finally:
if tar.stdout:
tar.stdout.close()
tar.wait()
if tar.returncode:
print(f"错误: tar 打包失败,退出码: {tar.returncode}")
return tar.returncode
completed = subprocess.run(mkdir_cmd, check=False)
except FileNotFoundError:
print("错误: ssh 命令未找到")
return 1
if completed.returncode:
print(f"错误: 远程 tar 解包失败,退出码: {completed.returncode}")
print(f"错误: 远程 mkdir 失败,退出码: {completed.returncode}")
return completed.returncode
if not files:
print("没有需要同步的文件")
print("-" * 60)
print("同步完成!")
return 0
try:
for parent, group in sorted(grouped.items()):
dest = remote_dir if parent == "." else f"{remote_dir}/{parent}"
scp_cmd = [
*scp_base_args(info),
"-p",
"--",
*[str(p) for p in group],
f"{node}:{dest}/",
]
print(f"执行命令: {' '.join(scp_cmd)}")
subprocess.run(scp_cmd, check=True)
except subprocess.CalledProcessError as e:
print(f"错误: scp 执行失败,退出码: {e.returncode}")
return 1
except FileNotFoundError:
print("错误: scp 命令未找到,请确保已安装 openssh-client")
return 1
print("-" * 60)
print("同步完成!")
return 0
+57
View File
@@ -0,0 +1,57 @@
# discussion-notes
边讨论边把可复用结论写进项目里的 Markdown 笔记。笔记是活的:理解变了就改,不另存聊天稿。
## 什么时候使用
- 要进入讨论模式、沉淀到文档、整理笔记、单开文档。
- 适合学习概念、做技术选型、把讨论变成可回顾的结论。
- 不适合:一次性问答、必须进 skill 记忆库的沉淀(用 `memory-distill`)、也不要用来当会议逐字稿。
说「结束讨论」或「不用记了」就退出;之后不再自动改笔记。
## 安装
```bash
pouch add discussion-notes # 当前项目
pouch add discussion-notes -g # 全局
```
## 使用前准备
- 指定笔记路径,或接受默认 `000inbox/<topic>.md`(按 Obsidian 收件箱习惯)。
- 有明确话题名更好;没有的话 Agent 会问一次。
- 笔记写在当前项目仓库内。默认不 git commit。
## 使用示例
```text
进入讨论模式,记到 000inbox/podman-vs-docker.md:公司 Debian VM 上容器运行时选 Podman 还是 Docker。
```
```text
讨论 Quadlet 是什么,单开一篇概念笔记,并链到刚才的选型文档。
```
```text
这轮不用记,先只回答。
```
```text
结束讨论。
```
## Agent 会做什么
1. 确认路径:你指定的文件,或默认 `000inbox/<topic>.md`。新话题新建,续聊改已有文件。
2. 按内容选结构:选型/决策,或概念/原理。模板见 [reference.md](reference.md)。
3. 每轮先用中文回答你,再更新笔记里的结论、对比、约束;纠正旧理解时直接改,不留错误版本。
4. 只写可复用知识,不把问答全文抄进文档,不创建 `.raw.md`。
5. 你说结束时,可选加一节「更新」:搞懂了、纠正了、待动手。
## 如何判断完成
- 约定路径上有一份正在维护的 `.md`,结构能看出结论而不是聊天记录。
- 本轮新结论已经写进对应章节;过时说法被改掉,而不是追加在文末。
- 没有出现 `xxx.raw.md`,也没有未经确认的 git commit。
- 退出讨论后,Agent 不再自动改那份笔记。
+8 -1
View File
@@ -8,6 +8,13 @@
- 已有 `.drawio` 原型,希望补齐跳转、异常态、权限或规则说明。
- 需要一张能用于产品评审、开发对齐和测试设计的产品说明板。
## 安装
```bash
pouch add draw-prototype-flow # 当前项目
pouch add draw-prototype-flow -g # 全局
```
## 使用前准备
最好提供产品目标、目标用户、核心任务和已有需求材料。缺少细节时,Agent 会先列出必要假设;只有关键范围无法判断时才会询问。
@@ -34,4 +41,4 @@
## 如何判断完成
最终会得到可在 diagrams.net/Draw.io 打开的 `.drawio` 源文件。核心任务应能从入口走到结果,每个关键按钮有明确去向,异常状态有恢复动作,原型、动线和规格使用同一套页面与状态 ID。
最终会得到可在 diagrams.net/Draw.io 打开的 `.drawio` 源文件。你指定路径就用指定路径,没指定时 Agent 会在项目里给出文件名。核心任务应能从入口走到结果,每个关键按钮有明确去向,异常状态有恢复动作,原型、动线和规格使用同一套页面与状态 ID。
+53
View File
@@ -0,0 +1,53 @@
# eli5
用大图少字的单页 HTML,把一个话题讲给完全没背景的人听。
改编自 [anthropics/claude-plugins-community](https://github.com/anthropics/claude-plugins-community) 的 `eli5`。
## 什么时候使用
- `/eli5 DNS 怎么工作`
- 「像五岁小孩一样解释 RAG」
- 「画图讲一下这个项目的登录流程」
- 想要一份能转发的图解,而不是聊天长文
不适合:核对精确规格、安全审计、修代码。那类工作先用 eli5 抓大图,再换别的 skill 或原文档抠细节。
## 使用前准备
- 给出话题。可以是概念(DNS、RAG),也可以是当前项目里的一块(登录、发版)。
- 想指定输出路径就说一声;不说则写到项目根目录的 `eli5-<话题>.html`。
## 安装
```bash
pouch add eli5 # 当前项目
pouch add eli5 -g # 全局
```
## 使用示例
```text
/eli5 DNS 怎么工作
```
```text
像五岁小孩一样解释这个仓库的认证是怎么走的。
```
```text
eli5 RAG,输出到 docs/eli5-rag.html
```
## Agent 会做什么
1. 从你的话里取出话题。
2. 按「读者完全没背景」来讲,而不是装成对幼儿说话。
3. 写一页自包含 HTML:大图(inline SVG)、极少文字、至少 3 个可见步骤。
4. 把文件写到约定路径;聊天里只回路径和一句总收束。
## 如何判断完成
- 能用浏览器直接打开那份 HTML,不依赖外网资源。
- 画面上图大于字;没背景的人能看出「这东西怎么走」。
- 聊天没有再贴一遍长文解释。
+52
View File
@@ -0,0 +1,52 @@
---
name: eli5
description: >-
用大图少字的单页 HTML 把话题讲给完全没背景的人听。Use when the user types
/eli5 <topic>, says eli5, 像五岁小孩一样解释, 用小学生能懂的方式讲, 画图解释,
or asks for a dead-simple picture explainer of how something works.
---
# eli5
把话题讲给**完全没背景**的人听。交付物是一页自包含 HTML:大图、少字,不是聊天长文。
改编自 [anthropics/claude-plugins-community](https://github.com/anthropics/claude-plugins-community) 的 `eli5`(Thariq Shihipar)。原技能用 `$ARGUMENTS`;这里从用户请求里取话题。
---
## 何时使用
- `/eli5 <topic>`、`eli5`、像五岁小孩一样解释、用小学生能懂的方式讲、画图解释
- 用户要一份一眼能看懂的图解,而不是长文
## 不适用
- 精确规格、边界条件、安全审计、修代码
- 高保真 UI 或可编辑原型 → `draw-prototype-flow`
- 边讨论边写笔记 → `discussion-notes`
---
## 步骤
1. **取话题**。用户没给就问一次。
2. **读者设定**:零背景,不是幼儿口吻。去掉默认假设,不要装小孩说话。
3. **写一份自包含 HTML**(无外部 CSS / JS / 字体 / 图片 URL):
- 一页看完
- 结构用大图讲(inline SVG;可少量 emoji)
- 文字极短:标题、箭头旁短语、一句总收束
- 至少把核心机制拆成 3 个可见步骤或块
4. **落盘**。用户指定路径就用;否则写到当前项目根(没有项目就用 cwd)`eli5-<slug>.html`。Claude Code 若支持 artifact,可同时打开,**仍要写文件**。
5. **聊天里只给路径和一句总收束**,不要把正文再讲一遍。
## 注意事项
- 不为了好懂而编造机制。不确定就标「简化」,不要装精确。
- 不 git commit。
- 语言跟用户请求走;没点名时用简体中文。
## 验证
- 用浏览器或 `file://` 打开是单页,图明显大于字。
- 没背景的人能从画面看出「这东西怎么走」。
- 仓库或 cwd 里有那份 HTML;聊天没有墙一样的解释。
+41
View File
@@ -0,0 +1,41 @@
# host-sec-check
对一台 Linux/VPS 做入侵取向的安全检查:先取证,再给你一份结论和处置清单。默认不改机器。
## 什么时候用
- 「这台机做一次安全检查」
- 「好像被黑了 / 有矿机 / 服务无故 OOM」
- 「看看哪些端口对公网开着」
- 检查另一台机器时,直接点名主机:`对 vvps4 跑 /host-sec-check`
适用于单台 Linux(systemd + 常见 Docker/k3s)。不是应用代码审计,也不是云账号合规审计。
## 使用前准备
- SSH 能上(别名或 `user@host`)
- 尽量有 sudo 或 docker 组(没有也能做浅查)
- 告诉 Agent 哪张 IP 是公网,方便从外面探测
- 若要清理、关端口、重启:明确说「同意处置」
## 使用示例
```text
对 ace@vvps2 做 host-sec-check,只出报告先不要改。
```
```text
用 host-sec-check 查 vora3,公网 IP 是 x.x.x.x,发现未授权的就列处置项问我。
```
## Agent 会做什么
1. 采集账号、监听端口、cron/systemd、Docker/k3s、`ld.so.preload`、内存/hugepage。
2. 怀疑进程隐藏时,用容器读宿主机,避免被矿机库骗过。
3. 从另一台机器探测公网端口(含 IPv6,如果有)。
4. 给出结论:是否沦陷、入口、对外端口、建议处置。
5. 你点头后才停容器、占位矿机文件、收端口或重启。
## 如何判断完成
报告里有结论表;若做了处置,公网已关端口连不上,业务域名仍可用。密钥、token 不应出现在完整原文里。
+130
View File
@@ -0,0 +1,130 @@
---
name: host-sec-check
description: >-
Linux/VPS 主机安全检查与入侵取证:先取证再处置。覆盖矿机、ld.so.preload 进程隐藏、带宽挂机容器、对外端口、SSH/Docker/k8s 入口、cron/systemd 持久化、hugepage/OOM。
Use when: 安全检查, 被黑, 入侵, 矿机, 排查端口, 对公网放开, rpcbind, 挂机赚钱, Portainer, 安全审计, host security, compromise, cryptominer, exposure, /host-sec-check.
---
# host-sec-check
对一台 Linux 主机做安全检查。默认 **只读取证 + 出报告**。清理、关端口、重启、轮换密钥必须用户明确同意后再做。
需要:SSH 目标(`user@host` 或 ssh config 别名)、哪张网卡/IP 算公网。没有公网 IP 就只报「监听面」,并标明未做外部探测。
## 不适用
- Web 应用代码审计、依赖 CVE 扫描(可在报告里列为后续)
- 云厂商安全组控制台(能查就查,查不到就写「未验证」)
- 未获准的破坏性操作(`rm -rf`、整盘 wipe、强制重启)
## 铁律
1. **先取证,后动手。** 重启、删文件、停服务会毁掉证据。
2. **不要把密钥打进对话。** token、密码、authorized_keys、kubeconfig 只写「存在/已轮换/路径」,不贴原文。
3. **sudo 失败不要停。** 用户在 `docker` 组时,用特权容器 `nsenter` 看宿主机(见 [references/access.md](references/access.md))。
4. **怀疑进程隐藏时,不要只信宿主机 `ps`。** 从**不加载宿主机 `ld.so.preload` 的容器**读 `/proc` 和文件。
5. 宿主机 `chattr` 可能被隐藏库挡住;改 immutable 文件用 **Ubuntu/Alpine 自带 chattr** 操作 bind-mount 的 `/host/...`。
收集脚本(可选,先跑再解读):
```bash
bash <skill-dir>/scripts/collect.sh USER@HOST
```
`<skill-dir>` 是本 skill 目录。脚本只采集,不修改系统。
## 流程
按顺序做。每步记下「正常 / 可疑 / 未查到」。完整项见 [references/checklist.md](references/checklist.md)。
### 1. 快照
- 主机名、发行版、uptime、负载、`free -h`、`df -h`、公网/内网/Tailscale IP
- `ss -lntup`(最好 root)
- `docker ps -a`、compose 项目
- 若有 k3s/k8s:agent 是否在跑、能否 `kubectl get pods -A`
### 2. 入口
- sshd:`Port`、`PermitRootLogin`、`PasswordAuthentication`、`authorized_keys`(root 与所有 UID≥1000)
- `last` / `lastb` / `journalctl -u ssh`:异常登录;**无交互登录却出现 docker run 也是入口信号**
- sudoers、`docker` 组(= root)、NOPASSWD
- 对公网的管理面:Portainer / Cockpit / 面板 / k8s apiserver / Docker 端口,尤其 `-p 0.0.0.0:…` 且挂了 `docker.sock` 或 `/:/host`
- 单元文件或 compose 里的明文 token(k3s `--token`、agent secret)
### 3. 持久化
读 [references/persistence.md](references/persistence.md)。至少查:
- crontab(root 与所有用户)、`/etc/cron.*`
- `/etc/systemd/system/*.service`、timers
- `/etc/ld.so.preload`(用**外部** cat/hexdump,宿主机 cat 可能被藏空)
- `chattr +i` 的异常二进制(`lsattr`)
- 隐藏 Docker 数据根(非 `/var/lib/docker` 的 dockerd `--data-root`)
### 4. 进程与隐藏库
- 从特权容器列 `/proc/*/cmdline`(绕过 preload)
- 对照宿主机 `ps`:只在容器视角出现的进程 = 隐藏
- `/proc/*/maps` 是否映射陌生 `.so`(`libgcwrap`、`libprocesshider`、随机名)
- 矿机常见名:`perfcc`、`xmrig`、`kdevtmpfsi`、`javavm64`;同体积多副本(例如都是 11MB)
- hugepage:`grep Huge /proc/meminfo`。`nr_hugepages>0` 且进程列表对不上,多半是矿机预留
### 5. 容器与编排
- `docker ps -a`:镜像名含 earnfm / packetstream / repocket / traffmonetizer / honeygain / proxyrack / bitping / antgain 等挂机池
- inspect:`Privileged`、Binds(`docker.sock`、`/:/host`)、Ports 是否 `0.0.0.0`
- 第二套 dockerd / 异常 `--bridge`
- k8s:节点上是否有与 Git 仓库无关的 workload;控制面不健康时从 kubelet `pods/*/etc-hosts` 读 Pod 名
### 6. 对外暴露
- 本机 `0.0.0.0` / `[::]` 监听
- **从另一台机器探测公网 IP**(含 IPv6)。只看 `ss` 会漏安全组,只看安全组会漏「监听但没防火墙」
- 高危:`rpcbind :111`、明文 HTTP 应用端口、Docker/Portainer、k8s 6443、数据库
- 管理 SSH 若可走 Tailscale,公网 SSH 算多余攻击面
### 7. 资源与日志
- 无 swap + 高 hugepage → 应用易 OOM
- `journalctl` / `auth.log` 在处置时间点附近;日志空也要记
- 出站连接:`ss -tp` 看陌生 IP(不要把结果里的密钥打出来)
### 8. 补漏(本次 vvps2 没做或做得浅的)
见 checklist 后半:补丁、SUID、世界可写、意外用户、IPv6、定时器、内核模块、出站 C2、云安全组、备份是否可用。
## 报告格式
先给结论(是否沦陷、最可能入口、对外还开着什么),再给证据表,最后给 **需用户点头的处置清单**。不要默默清理。
```
结论: 沦陷 / 可疑 / 未见异常
最可能入口: …
对外端口: …
持久化: …
隐藏/矿机: …
容器/编排: …
未查项: …
建议处置: (等确认)
```
## 处置(仅在用户同意后)
原则见 [references/cleanup.md](references/cleanup.md)。
- 停并删除未授权容器;镜像也删
- 矿机二进制:删掉后用 **空文件 + `chattr +i` 占路径**
- 清空并锁定 `/etc/ld.so.preload`
- 关公网绑定:改成 `127.0.0.1` 或 Tailscale IP(若回源走 Tailscale 主机名,必须绑 TS IP,不能只绑 loopback)
- 停无用服务(如 `rpcbind.socket` + `rpcbind.service`,必要时 mask)
- 重启才能卸掉仍映射已删 `.so` 的老进程(sshd/dockerd)。先取证再重启
- 轮换:暴露过的 token、面板、k3s bootstrap。控制面 API 不通就不要假轮换,写阻塞原因
## 验证
- 公网探测:已关端口失败;该留的业务域名仍 200
- `ss`:无 `0.0.0.0:已关端口`
- `grep -l libgcwrap /proc/*/maps`(或同类库)重启后为 0
- 占位文件仍在且 immutable
- 业务容器 `restart: always` 仍在
@@ -0,0 +1,48 @@
# 取证入口(无 sudo 时)
目标用户常见情况:`sudo` 要密码,但在 `docker` 组。Docker 组等价于 root。
## 读宿主机(绕过 preload)
容器用自己的 libc,**不会**加载宿主机 `/etc/ld.so.preload`。
```bash
docker run --rm -i --privileged --pid=host --net=host -v /:/host alpine:latest \
cat /host/etc/ld.so.preload
hexdump -C /host/etc/ld.so.preload # 在容器内
```
列隐藏进程:
```bash
docker run --rm --privileged --pid=host alpine:latest sh -c \
'for d in /proc/[0-9]*; do
pid=${d#/proc/}
cmd=$(tr "\0" " " < "$d/cmdline" 2>/dev/null) || continue
[ -n "$cmd" ] && echo "$pid $cmd"
done'
```
## 改宿主机(systemctl / chattr)
`chroot /host /usr/bin/chattr` 会加载宿主机 preload,可能 **stat 不到** 被藏文件。
用发行版自带 chattr 操作 `/host/...`:
```bash
docker run --rm -i --privileged --pid=host --net=host -v /:/host ubuntu:latest bash -s <<'SH'
chattr -i /host/etc/ld.so.preload
: > /host/etc/ld.so.preload
chattr +i /host/etc/ld.so.preload
lsattr /host/etc/ld.so.preload
SH
```
systemctl / reboot:
```bash
docker run --rm --privileged --pid=host --net=host alpine:latest \
nsenter -t 1 -m -u -i -n -- systemctl disable --now UNIT
```
不要用业务容器当跳板;用 `alpine`/`ubuntu` 临时 `--rm`。查到一半时 `docker ps` 里出现的随机名容器就是这个跳板。
@@ -0,0 +1,75 @@
# 检查项
每项:做了 / 跳过(原因) / 可疑。
## A. 快照
- [ ] 主机名、OS、内核、uptime
- [ ] CPU/负载、内存(含 hugepage、swap)、磁盘
- [ ] 网卡:公网 v4/v6、内网、Tailscale/VPN
- [ ] 监听端口 + 进程(root `ss -lntup`)
- [ ] 本机防火墙:nft/iptables/ufw;INPUT 策略
- [ ] 云安全组(做不到就写未查)
## B. 身份与入口
- [ ] sshd 配置:端口、root、密码、Pubkey
- [ ] 所有 `authorized_keys`(含 `/root`、`/home/*`、git 用户)
- [ ] `last` / `lastb` / ssh journal
- [ ] `/etc/passwd` 里 UID 0、系统外用户、登录 shell
- [ ] sudoers、NOPASSWD、docker/lxd/libvirt 组
- [ ] 对公网的面板:Portainer/Cockpit/宝塔/1Panel/k8s/Docker TCP
- [ ] 单元或 compose 里的明文 bootstrap token
## C. 持久化
- [ ] 用户与系统 crontab、`/etc/cron.*`
- [ ] systemd 服务与 timer(非发行版默认)
- [ ] `ld.so.preload` + 对应 `.so`(容器视角 hexdump)
- [ ] `lsattr` 发现的 immutable 异常文件
- [ ] rc.local、profile.d、bashrc 后门
- [ ] 第二套 container runtime
## D. 进程
- [ ] 容器视角 vs 宿主机 `ps` 差集
- [ ] maps 里的陌生 so
- [ ] 矿机/挖矿/proxy 客户端特征名与同体积副本
- [ ] hugepage 预留但无对应进程
- [ ] 高 CPU 且不在业务名单里的进程
## E. 容器 / k8s
- [ ] `docker ps -a` 与 inspect(privileged、sock、`/:/host`、`0.0.0.0`)
- [ ] 挂机赚钱镜像名单
- [ ] k8s/k3s:节点 Ready、该节点 Pod 列表
- [ ] API 不通时:kubelet `pods/*/etc-hosts`、crictl
- [ ] 工作负载是否能在 Git/编排仓库里对上
## F. 暴露
- [ ] `0.0.0.0` 与 `[::]` 监听清单
- [ ] **从外部**扫公网 IPv4(和 IPv6)
- [ ] rpcbind :111、DB、Redis、应用明文端口
- [ ] 管理 SSH 是否可收到 VPN
- [ ] 证书/域名入口是否可被 IP:端口绕过
## G. 补漏(容易漏)
- [ ] IPv6 公网地址同样在听
- [ ] 世界可写目录、异常 SUID
- [ ] 未装补丁 / `apt` 过期很久
- [ ] 出站长连接(C2)
- [ ] 内核模块 `lsmod` 异常
- [ ] 时间偏移(影响日志)
- [ ] 备份是否还能用(清理前不要删数据盘)
- [ ] 日志被清空或 journal vacuum
- [ ] Web 根目录 webshell(若有 nginx/caddy/apache)
- [ ] `.git` 或备份文件可下载
- [ ] 云 metadata 是否对容器开放
## H. 报告与处置确认
- [ ] 结论表
- [ ] 用户确认后再改
- [ ] 改完公网+域名双侧验证
@@ -0,0 +1,59 @@
# 处置(用户同意之后)
## 顺序
1. 证据已落盘(collect 输出、关键 ls/hexdump/docker inspect)
2. 停进程 / 停容器
3. 删或占位文件
4. 关公网端口
5. 最后才重启(卸已加载的隐藏库)
## 占位
对矿机路径(`/usr/bin/perfcc`、`/tmp/javavm64`、隐藏 docker 根名等):
```bash
chattr -i "$path" 2>/dev/null
rm -f "$path"
: > "$path"
chmod 000 "$path"
chattr +i "$path"
```
`/etc/ld.so.preload` 占空文件,权限 `644` + immutable,让动态链接器能读到「没有库」。
目录型隐藏根:停进程并卸挂载后删除,再在同名路径放 immutable 空文件,阻止再 `mkdir`。
改 immutable 时用容器自带 `chattr` 操作 `/host/...`,不要 `chroot` 到宿主机再跑 chattr。见 access.md。
## 容器
```bash
docker stop NAME && docker rm NAME
docker rmi IMAGE
```
不要 `docker system prune -a`。
## 端口
- 入口在本机 loopback → `127.0.0.1:PORT:CONTAINER`
- 入口走 Tailscale 主机名 → 必须绑 **当前 Tailscale IPv4**,不能只绑 127.0.0.1
- 可同时绑 loopback + Tailscale,禁止 `0.0.0.0` / `[::]`
改 compose 后 force-recreate,再从公网 IP 和业务域名两边探测。
## rpcbind
```bash
systemctl disable --now rpcbind.socket rpcbind.service
systemctl mask rpcbind.socket rpcbind.service
```
无对外 NFS 再做。确认 `:111` 消失。
## 重启
重启前确认业务是 `restart: always`,并告知会短暂中断。
重启后:`grep -l <so名> /proc/*/maps` 为 0;若已清 hugepage 则 `HugePages_Total` 为 0;域名 200。
@@ -0,0 +1,40 @@
# 持久化位置
按这个名单扫。出现「刚被排查时时间戳又刷新」= 仍有 watchdog。
## 定时
- `crontab -u root -l` 以及 `/var/spool/cron/crontabs/*`
- `/etc/cron.d/` `/etc/cron.hourly/` `/etc/cron.daily/` `/etc/cron.weekly/`
- systemd timers:`systemctl list-timers --all`
## 服务
- `/etc/systemd/system/*.service`(尤其非发行版默认的)
- `ExecStart` 指向 `/tmp`、`/dev/shm`、`/root/.config`、随机短名
- `Restart=always` 的陌生服务
## 动态链接器
- `/etc/ld.so.preload`:内容常是 `/lib/libXXX.so`(约十几字节)。宿主机 `cat` 可能显示空,用容器 hexdump
- 对应 `.so` 可能 `chattr +i`、setgid
- 常见名:`libgcwrap.so`、`libpprocps.so`、`libfsnldev.so`、`libprocesshider.so`
## 二进制与分身
- `/usr/bin` `/bin` `/usr/local/bin` `/tmp` 下刚改过的可执行文件
- 同字节大小的多份拷贝(矿机改名)
- 占位对抗:空文件 `chmod 000` + `chattr +i`(见 cleanup.md)
## 隐藏运行时
- 第二套 dockerd:`--data-root` 不在 `/var/lib/docker`,`--host unix://` 异常路径
- 混淆文件名(把 docker 子命令拼进无关单词)
- k3s 的 `containerd-shim` 被借去跑 **moby** namespace + 异常 `--address`
## 登录与启动
- `~/.bashrc` / `/etc/profile.d` 里 curl|bash
- `/etc/rc.local`
- SSH `authorized_keys` 多出来的 key
- 意外用户、UID 0 非 root
+53
View File
@@ -0,0 +1,53 @@
#!/usr/bin/env bash
# 只采集,不修改。用法: collect.sh [user@]host
# 输出写到 stdout;完整日志建议重定向到文件。
set -euo pipefail
HOST="${1:?usage: collect.sh user@host}"
SSH=(ssh -o BatchMode=yes -o ConnectTimeout=15 "$HOST")
echo "===== host-sec-check collect $(date -u +%Y-%m-%dT%H:%M:%SZ) host=$HOST ====="
remote() {
"${SSH[@]}" bash -s <<'EOS'
set +e
echo "----- id / os / uptime -----"
id; hostname; uname -a
. /etc/os-release 2>/dev/null; echo "$PRETTY_NAME"
uptime
echo "----- mem / hugepage / disk -----"
free -h
grep -E "MemAvailable|HugePages_|Hugetlb|SwapTotal" /proc/meminfo
df -h
echo "----- addresses -----"
ip -4 addr; ip -6 addr show scope global
echo "----- listen (may miss pid without root) -----"
ss -lntup 2>/dev/null || netstat -lntup 2>/dev/null
echo "----- docker -----"
docker ps -a --format "table {{.Names}}\t{{.Image}}\t{{.Status}}\t{{.Ports}}" 2>/dev/null || echo "no docker"
echo "----- preload (host cat; may be hidden) -----"
ls -la /etc/ld.so.preload 2>/dev/null
echo "preload_host=[$(cat /etc/ld.so.preload 2>/dev/null)]"
echo "----- cron files -----"
ls -la /etc/cron.d /etc/cron.hourly /etc/cron.daily 2>/dev/null
echo "----- systemd extras -----"
ls /etc/systemd/system/*.service 2>/dev/null
echo "----- users uid0 / human -----"
awk -F: '$3==0 || ($3>=1000 && $3<65534) {print}' /etc/passwd
echo "----- sshd -----"
grep -E "^(Port|PermitRootLogin|PasswordAuthentication|PubkeyAuthentication)" /etc/ssh/sshd_config /etc/ssh/sshd_config.d/* 2>/dev/null
echo "----- last -----"
last -a | head -15
EOS
}
remote
# docker 组:从容器读 preload 真值
if "${SSH[@]}" "groups | grep -qw docker" 2>/dev/null; then
echo "----- preload via alpine (bypass host libc) -----"
"${SSH[@]}" "docker run --rm --privileged -v /:/host alpine:latest hexdump -C /host/etc/ld.so.preload" 2>/dev/null \
|| echo "alpine hexdump failed"
fi
echo "===== collect done ====="
echo "下一步: 按 SKILL.md 解读;不要把本输出里的密钥贴进聊天。"
+13
View File
@@ -4,6 +4,13 @@
版本文件、worktree、分支、PR/MR 和合并。普通发布优先走“更新说明 + annotated tag”的
短路径;只有发布要求修改仓库内容时,才进入准备版本的分支流程。
## 安装
```bash
pouch add manage-release # 当前项目
pouch add manage-release -g # 全局
```
## 使用前准备
- 项目已经是 Git 仓库,并配置了可访问的远端。
@@ -13,6 +20,12 @@
- 请求里写清目标版本、基线分支,以及允许执行到哪一步。没有明确授权的远端写操作
不会执行。
## 使用示例
```text
使用 manage-release,为已经合并到 main 的 1.6.0 发布 v1.6.0 tag。不要创建 Forge Release。
```
## 常见用法
### 确定下一个版本号
+7
View File
@@ -12,6 +12,13 @@ memory-distill 提炼一下刚刚 ack 执行过程中可以沉淀的知识。
也会响应「沉淀记忆」「回顾刚才记下来」「更新记忆」等说法。
## 安装
```bash
pouch add memory-distill # 当前项目
pouch add memory-distill -g # 全局
```
## 使用前准备
通常不用准备路径。Agent 会:
+40
View File
@@ -0,0 +1,40 @@
# note-style
把已有材料整理成可长期查阅的知识笔记:压缩空话、统一术语,保留事实、公式、条件和参考。
## 什么时候使用
- 要把聊天稿、草稿或 AI 生成的长文改成能放进知识库的笔记。
- 要整理 Obsidian 笔记,去掉重复和口语。
- 要把技术原理或操作方案写成教科书 / 手册口吻。
不适合:还在讨论、还没有可依据的材料,或需要 Agent 发明新结论。
## 使用前准备
- 给出要整理的材料(文件路径、粘贴正文,或当前对话里的那一段)。
- 可选:目标读者、篇幅要求、笔记路径。不说则在原材料风格上压缩约三成。
## 使用示例
```text
用 note-style 把这篇对话整理成可放进 Obsidian 的技术笔记,保留公式和适用条件。
```
```text
把 docs/draft-auth.md 改写成手册口吻,删掉空话,有疑点列待核实项。
```
## Agent 会做什么
1. 抽出事实、定义、步骤、条件和参考,删掉铺垫和重复。
2. 按材料类型选结构(原理或操作),用中性书面语重写全文。
3. 先给你完整笔记;只有事实或技术疑点时才追加「待核实项」。
不会补没有依据的结论,也不会为缩短篇幅删掉必要条件。
## 如何判断完成
- 得到一份完整笔记,而不是改写说明。
- 原文里的公式、条件、安全警告和参考还在。
- 没有聊天式过渡或段末复述。
+89
View File
@@ -0,0 +1,89 @@
---
name: note-style
description: 将现有材料整理、压缩或改写为可长期查阅的知识笔记,采用教科书、技术手册或标准说明文风格,并保留事实、公式、适用条件、安全警告和参考资料。适用于用户要求整理 Obsidian 笔记、消除聊天或 AI 写作痕迹、减少重复和空话、统一术语与技术表达,或将技术原理、操作方案改写为结构化知识文档的任务。
---
# 整理知识笔记
## 工作流程
1. 识别材料的核心问题、目标读者和材料类型。
2. 提取事实、定义、公式、条件、因果关系、步骤、例证、边界、安全警告和参考资料。
3. 删除与核心问题无关的铺垫,并合并重复内容。
4. 按材料类型选择结构并重写全文。
5. 检查技术准确性、术语、格式、重复和表达密度。
6. 输出完整笔记;存在事实或技术疑点时,追加“待核实项”。
## 保留与删减
- 保留原文中的事实、公式、适用条件、例外、安全限制和参考资料。
- 不自行补充没有依据的结论,也不为缩短篇幅而删除必要条件或公式推导。
- 围绕核心问题组织内容,删除无关铺垫。
- 使每句话至少承担一种功能:定义概念、说明条件、解释因果、给出步骤、提供例证或指出边界。
- 使同一知识点只完整解释一次。避免在摘要、正文、表格、总结和检查表之间复述。
- 在不损失必要信息的前提下,默认将篇幅压缩约 30%;若用户另有篇幅要求,以用户要求为准。
## 语言规范
- 使用中性、准确、简洁的书面语。
- 直接陈述内容,不添加聊天式过渡、开场铺垫、段末复述或写作过程说明。
- 删除情绪化、评价性、劝导性、宣传性和表演式表达,如“非常美妙”“加分项”“更稳妥”“请仔细看”。
- 不使用反问、口号、拟人化表达或用感叹号制造重点。
- 减少“通常、可能、建议、优先、具体来看”等模糊限定词。确有不确定性时,明确不确定性的来源和适用条件。
- 每段只表达一个主题;能用一句话说明的内容不扩写成多句。
- 将评价句改写为“条件、机制、结果”或“对象、操作、限制”。
## 技术表达
- 首次出现专业术语时给出简短定义,后文统一名称。
- 明确符号、变量、单位、对象类型、参考系、输入输出和变换方向。
- 区分严格定义、直观解释和示例,不用比喻替代定义。
- 每个概念最多保留一个必要的例子或比喻;使用比喻后,以正式术语重述结论。
- 检查概括的适用范围,将无条件的“X 代表 Y”改为带条件的准确表述。
- 不擅自修正不确定或可能有误的技术内容,将其列入“待核实项”。
## 结构选择
根据材料类型采用下列骨架,并按实际内容删去不需要的章节。
### 技术原理类
1. 问题或目标
2. 符号与定义
3. 原理或推导
4. 示例
5. 适用条件与限制
6. 简短结论
### 操作与方案类
1. 目标与适用范围
2. 基本原则
3. 操作步骤
4. 分类处理
5. 安全边界
6. 检查表
不要同时保留内容重复的“摘要”“重新解释”“具体调整”和“总结”。摘要已经给出结论时,正文只提供依据和展开。
## 格式规范
- 使用描述性标题,避免“到底是什么”“究竟该怎么做”等口语化标题。
- 保持标题编号连续、层级一致。
- 仅对首次定义的术语、关键条件和禁止事项使用粗体。
- 减少引号和括号,统一数学符号、变量格式和单位格式。
- 保留适合 Obsidian 的 Markdown、YAML frontmatter、Wiki-links、表格、公式和代码块;除非用户要求,不改变其语义。
## 完成检查
- 检查同一结论是否出现在两个以上位置,只保留信息最完整且位置最合适的一处。
- 检查相邻段落能否分别用一句话概括;含义相同时合并。
- 检查所有评价句是否已改为可验证的条件、机制、结果、操作或限制。
- 检查技术概括、符号定义、事实和适用条件是否成立。
- 检查内部链接、表格、公式、代码块、安全警告和参考资料是否完整保留。
## 输出
1. 先输出整理后的完整笔记。
2. 仅在存在技术或事实疑点时追加“待核实项”,逐条列出疑点。
3. 不解释改写过程,不输出修改心得,不复述本技能的要求。
+4
View File
@@ -0,0 +1,4 @@
interface:
display_name: "教科书式笔记整理"
short_description: "将材料压缩并改写为准确、简洁的结构化知识笔记"
default_prompt: "使用 $note-style 将这份材料整理为可长期查阅的知识笔记。"
+9
View File
@@ -11,6 +11,15 @@ ORC 是显式调用的薄路由器:只把开发、版本发布、DEB 和 Docke
只做单一领域任务时可以直接调用对应 Skill;ORC 不替代它们的安全规则。
## 安装
```bash
pouch add orc # 当前项目
pouch add orc -g # 全局
```
`$orc` / `/orc` 只在显式调用时运行。下游阶段还需要已安装 `$ack`、`$manage-release` 或 `$builder`。
## 使用前准备
- Orca 正在运行并启用了 orchestration。
+65 -16
View File
@@ -1,16 +1,39 @@
# pouch
`pouch` 用于创建、维护、安装和发布团队自研的 Agent Skill。Skill 的唯一来源位于
`~/.pouch/skills/<name>/`,安装到各 Agent 时使用软链接。
创建、维护、安装和发布团队自研的 Agent Skill。唯一来源在 `~/.pouch/skills/<name>/`,装到各 Agent 时用软链。
## 什么时候使用
- 想把项目里的重复工作沉淀成一个 skill。
- 想完善、校验、转正或发布已有 skill。
- 想把自研 skill 安装到当前项目或全局 Agent。
- 想让 Agent 代装 pouch,或介绍有哪些 skill、在当前项目接入。
- 想检查并修复 skill 软链接。
- 想优化某个已有 skill 的结构,减少点中后灌进上下文的内容。
## 使用前准备
- 已把本仓库 clone 到 `~/.pouch` 并跑过 `./install.sh`(会把本 skill 装到所有 Agent)。
- Python 3;`~/.local/bin` 已加入 `PATH`。
- 至少一种 Agent:Cursor、Claude Code、Codex,或 agents 标准目录。
- 要推到远端时,本仓库的 git remote 可写。
CLI 命令细节见仓库里的 [pouch/README.md](../../pouch/README.md)。
## 使用示例
```text
帮我安装 pouch。
帮我装 https://git.yumee.top/laily/pouch
有哪些自研 skill,各用一句话介绍。
把这个项目该用的 skill 接上。
帮我把「评审 PR 时按这份清单走」做成一个自研 skill。
pouch 帮我检查 discussion-notes 这个 skill。
pouch 帮忙优化 ack 这个 skill 结构,减少 token 浪费。
把 ack 装到当前项目的 Cursor。
检查一下 skill 软链,坏了就修。
```
## 创建一个 skill
```bash
@@ -32,6 +55,8 @@ pouch check my-skill
pouch finalize my-skill
```
转正不会自动 commit、push 或安装。
## 优化已有 skill 的结构
skill 能用但正文太长、所有模式写在一份 `SKILL.md` 里,或点中后把用不到的参考一并读进上下文时:
@@ -40,42 +65,66 @@ skill 能用但正文太长、所有模式写在一份 `SKILL.md` 里,或点
pouch 帮忙优化 ack 这个 skill 结构,减少 token 浪费
```
Agent 会先测量该 skill 的体积,再按分层加载改 SSOT:`SKILL.md` 只留路由和全模式边界,细节按条件读 `references/`。不会为了缩字删掉发版/部署一类的安全限制。改完仍需 `pouch check` 通过。
Agent 会先测量该 skill 的体积,再按分层加载改 SSOT:`SKILL.md` 只留路由和全模式边界,细节按条件读 `references/`。不会为了缩字删掉发版、部署一类的安全限制。改完仍需 `pouch check` 通过。
不要对社区 catalog skill 的安装目录直接改;那些不是本仓库的 SSOT。
## 提交和发布
只提交:
```bash
pouch publish skills/my-skill -m "add my-skill"
```
提交并推送:
```bash
pouch publish skills/my-skill -m "add my-skill" --push
```
## 安装
## 安装与维护
安装到当前项目的 Codex:
`./install.sh` 会执行 `pouch bootstrap`,把本 skill 全局装到所有 Agent。也可以随时重跑:
```bash
pouch add my-skill -a codex -y
pouch bootstrap
```
安装到全局 Codex:
安装其它 skill:
```bash
pouch add my-skill -a codex -g -y
pouch add my-skill -a codex -y # 当前项目的 Codex
pouch add my-skill -a codex -g -y # 全局 Codex
pouch select # 交互多选,默认当前项目
pouch select -g # 交互多选,装到全局
```
项目状态初始化(目前用于 ack):
```bash
pouch init ack
pouch init ack --project ~/app
```
builder / deployer 不要 `pouch init`,对 Agent 说「用 builder / deployer 初始化」。
软链出问题:
```bash
pouch status
pouch doctor
pouch doctor --fix
```
## Agent 会做什么
1. 本机还没有 `~/.pouch` 时,按安装剧本 clone 或链接,跑 `install.sh`,再介绍 builtin skill。
2. 在业务仓库里按文件信号建议 skill,`pouch add` 之后问一句要不要初始化 ack / builder / deployer。
3. 创建草稿到 `~/.pouch/.drafts/`,按你的想法完善 `SKILL.md` 和给人看的 `README.md`。
4. 跑 `pouch check`;你确认后再 `finalize` 到 `skills/`。
5. 安装只建指向 SSOT 的软链,不复制文件。默认装当前项目;只有你明确要求时才加 `-g`。
6. 优化结构时先测量再改 SSOT,不删发版、部署类安全限制。
7. 未要求时不 commit、不 push、不全局安装。
## 如何判断完成
- `pouch check <name>` 输出校验通过。
- 正式 skill 同时包含 `SKILL.md` 和 `README.md`。
- `pouch status` 或 `pouch doctor` 显示目标软链接正常。
- 代装完成后 CLI 可用,且介绍了 builtin skill 的一句话和示例请求。
- 项目接入后,建议的 skill 已 `pouch add`;ack / builder / deployer 要么已初始化,要么明确问过。
- 优化结构时:审计报告有前后体积,安全限制仍在 `SKILL.md`,没有为缩字删掉模式。
+25 -11
View File
@@ -1,10 +1,10 @@
---
name: pouch
description: >-
创建、校验、转正、安装或发布 ~/.pouch 自研 skill,初始化 skill 项目状态,
或优化 skill 分层加载结构以减少 token 浪费。
触发词:pouch、skiff、自研 skill、创建 skill、publish skill、安装自研 skill、
初始化 skill、优化 skill 结构、减少 token 浪费、skill 太长、分层加载。
创建、校验、转正、安装或发布 ~/.pouch 自研 skill;代装 pouch、介绍 builtin
skill、在项目里接入并引导初始化;或优化 skill 分层结构以减少 token 浪费。
触发词:pouch、skiff、帮我装 pouch、有哪些 skill、skill 怎么用、接入当前项目、
初始化 skill、创建 skill、publish skill、优化 skill 结构、分层加载。
---
# pouch 自研 Skill 工作流
@@ -15,24 +15,38 @@ SSOT 固定在 `~/.pouch/skills/<name>/`。内容通过 **symlink** 分发到各
## 选择模式
- 安装 pouch、帮我装 pouch、代装、丢仓库 URL、`~/.pouch` 还不存在:读
[install.md](references/install.md) 并执行。
- 有哪些 skill、skill 怎么用、介绍 skill:执行「介绍 builtin skill」。
- 在这个项目接入、把 skill 接上、引导初始化:读
[onboard.md](references/onboard.md) 并执行。
- 创建、完善、转正草稿:执行「创建新的 skill」。
- 行为不对、触发不准、校验失败、可复用优化回流:执行「问题或优化回流」。
- 优化结构、减少 token 浪费、skill 太长、分层加载:执行「优化 skill 结构」。先读
[token-structure.md](references/token-structure.md)。
- 优化结构、减少 token 浪费、skill 太长、分层加载:执行「优化 skill 结构」。
- 安装、卸载、浏览、init、status:执行「安装与维护」。
---
## 在项目中使用 skill
## 介绍 builtin skill
先浏览可用的 builtin skill,再安装到当前项目:
不要通读各 README,不要把 `pouch add --list` 的 description 念给用户。跑:
```bash
python3 <pouch-skill-dir>/scripts/catalog.py
```
把表格里的「做什么」和「可以这么说」原样告诉用户。用户点名再 `pouch add`。
## 在项目中使用 skill
已点名则直接安装;没点名或要按仓库判断:读 [onboard.md](references/onboard.md)。
```bash
pouch add --list
pouch add <name> -a cursor -a claude -a codex -a agents -y
```
`pouch add <name>` 默认安装到当前项目;只有用户明确需要所有项目使用时才加 `-g`。安装结果是指向 `~/.pouch/skills/<name>/` 的软链,不要在 Agent 目录创建副本。
默认当前项目;只有用户明确需要所有项目使用时才加 `-g`。只建指向
`~/.pouch/skills/<name>/` 的软链,不要在 Agent 目录创建副本。
## 创建新的 skill
@@ -159,7 +173,7 @@ pouch remove discussion-notes -g -a cursor -y # 全局
pouch rm discussion-notes -g -y # rm 别名
```
浏览可用自研 skill:
浏览可用自研 skill(给 Agent 匹配用,不要念给用户):
```bash
pouch add --list
+65
View File
@@ -0,0 +1,65 @@
# 安装 pouch
用户说「帮我安装 pouch」「把 skill 装上」,或丢来仓库 URL / 本地路径时执行。
本文件是代装步骤的唯一详细来源。不要用 `pouch select`(必须交互式 TTY)。
不要在未确认时覆盖已有的 `~/.pouch`。CLI 只认 `~/.pouch`。
默认 remote:`https://git.yumee.top/laily/pouch.git`。用户给了 URL 或本地路径则用用户的。
## 1. 确认本机
需要 `python3` 和 `git`。缺了先说明怎么装,不要继续。
## 2. 让 `~/.pouch` 指向本仓库
按现状只走一条:
| 现状 | 动作 |
| --- | --- |
| 当前目录就是这份仓库(有 `install.sh` 和 `skills/`),且没有 `~/.pouch` | `ln -sfn "$(pwd)" "$HOME/.pouch"` |
| `~/.pouch` 已在且含 `skills/`、`install.sh` | 沿用 |
| 只有 `~/.skills` | `mv ~/.skills ~/.pouch` |
| 都没有 | `git clone <URL> ~/.pouch`。失败则问用户本地路径或可用 remote |
| `~/.pouch` 在,但不是这份仓库 | 停下问用户,不要删、不要覆盖 |
clone 私有 Git 失败时停下来问路径或认证,不要改 remote、不要改用交互式 `pouch select`。
## 3. 装 CLI 和 `pouch` skill
```bash
"$HOME/.pouch/install.sh"
export PATH="$HOME/.local/bin:$PATH"
command -v pouch
pouch status
```
`install.sh` 会改 rc,当前会话仍要 `export PATH`。不要让用户重开终端才继续。
它只全局安装 **pouch** 这一个 skill。其它 skill 不要默认全装。
## 4. 介绍 builtin skill,再问要装哪些
```bash
python3 "$HOME/.pouch/skills/pouch/scripts/catalog.py"
```
把表格里的「做什么」和「可以这么说」告诉用户。不要念 frontmatter `description`,
不要通读各 README。用户没点名就问一次:装哪些、全局还是当前项目。能判断就不要追问。
```bash
pouch add <name> -g -y # 全局,所有项目都能用
pouch add <name> -y # 仅当前项目
```
默认 `pouch add` 装到当前工作目录。cwd 是 pouch 仓库本身时用 `-g`,除非用户就在维护 pouch。
用户是在自己的应用仓库里请你代装,才做项目级安装。
## 5. 验收
```bash
pouch status
pouch doctor
```
软链异常则 `pouch doctor --fix`。已经装好时只汇报状态,问还要不要加 skill,不要重新 clone。
当前目录是业务仓库(不是 pouch 仓库)时,回到 SKILL.md,走「接入当前项目」。
+49
View File
@@ -0,0 +1,49 @@
# 在当前项目接入 skill
用户说「把 skill 接上」「在这个项目初始化 skill」「这个仓库该用哪些 pouch skill」时执行。
安装和初始化是两步,不要只 `pouch add` 就当接入完成。
当前目录是 pouch 仓库本身(有 `install.sh`、`skills/pouch/SKILL.md`、`bin/pouch`)时停:
这是维护 SSOT 的仓库,不要按业务项目接入。改 skill 走「创建」或「问题或优化回流」。
## 1. 看仓库信号
```bash
python3 <pouch-skill-dir>/scripts/suggest_skills.py
```
只根据脚本输出建议,不要为了「可能有用」推荐 eli5、note-style、host-sec-check 这类无仓库信号的 skill。
用户点名的除外。
## 2. 安装
把 `suggested` 和 `already` 都列为应安装的 skill(`already` 表示项目里已有状态,
软链仍要 `pouch add`,除非已经装上)。用户说「接上该用的」时只装这两类,不要全装 builtin。
只对 `init` 列问要不要初始化。
默认当前项目:
```bash
pouch add <name> -a cursor -a claude -a codex -a agents -y
```
只有用户明确要所有项目都能用时才加 `-g`。cwd 是 pouch 仓库时不要走到这一步。
## 3. 问一句要不要初始化
对已经装上、或本来就在的 ack / builder / deployer,看脚本的 `init` 列:
| Skill | 尚未初始化时对用户说 | 不要 |
| --- | --- | --- |
| ack | `/ack 初始化` 或 `pouch init ack` | 静默 `pouch init` |
| builder | 「用 builder 初始化」 | `pouch init builder` |
| deployer | 「用 deployer 初始化」 | `pouch init deployer` |
一次问清要初始化哪些。用户没点名不要自己跑初始化。ACK 初始化**不会**自动初始化
deployer / builder,需要的话要另说。
orc、manage-release 和其余 builtin 装上就能用,不必项目初始化。
## 4. 介绍怎么喊
装完后跑 `catalog.py`,只念本次装上的那几行「可以这么说」。
+168
View File
@@ -0,0 +1,168 @@
#!/usr/bin/env python3
"""Print a human-facing builtin skill list: one-liner + copy-paste example.
Reads each skill's README.md. Do not substitute SKILL.md descriptions.
"""
from __future__ import annotations
import argparse
import re
import sys
from pathlib import Path
_HEADING_RE = re.compile(r"^#{1,6}\s+")
_FENCE_RE = re.compile(r"^```(\w+)?\s*$")
def skills_dir() -> Path:
here = Path(__file__).resolve()
repo_skills = here.parents[2]
if (repo_skills / "pouch" / "SKILL.md").is_file():
return repo_skills
return Path.home() / ".pouch" / "skills"
def _strip_md(text: str) -> str:
text = re.sub(r"\[([^\]]+)\]\([^)]+\)", r"\1", text)
text = re.sub(r"`([^`]+)`", r"\1", text)
return " ".join(text.split())
def _first_sentence(text: str) -> str:
text = _strip_md(text).strip()
for sep in ("。", "!", "?"):
idx = text.find(sep)
if idx != -1:
return text[: idx + 1]
return text.rstrip("::") + ("。" if text else "")
def _lede(readme: str) -> str:
lines = readme.splitlines()
i = 0
while i < len(lines) and (
not lines[i].strip() or _HEADING_RE.match(lines[i])
):
i += 1
chunk: list[str] = []
while i < len(lines):
raw = lines[i]
stripped = raw.strip()
if not stripped:
break
if _HEADING_RE.match(raw) or stripped.startswith(("- ", "* ", "> ")):
break
chunk.append(stripped)
i += 1
return _first_sentence(" ".join(chunk))
def _section_body(readme: str, titles: tuple[str, ...]) -> str | None:
lines = readme.splitlines()
start = None
for i, line in enumerate(lines):
if line.startswith("## ") and line[3:].strip() in titles:
start = i + 1
break
if start is None:
return None
end = len(lines)
for j in range(start, len(lines)):
if lines[j].startswith("## "):
end = j
break
return "\n".join(lines[start:end])
def _first_example(readme: str) -> str:
body = _section_body(readme, ("使用示例", "常见用法"))
search = body if body is not None else readme
lines = search.splitlines()
i = 0
while i < len(lines):
fence = _FENCE_RE.match(lines[i].strip())
if not fence:
i += 1
continue
i += 1
block: list[str] = []
while i < len(lines) and not lines[i].strip().startswith("```"):
block.append(lines[i])
i += 1
for raw in block:
stripped = raw.strip()
if not stripped or stripped.startswith("#"):
continue
return stripped
i += 1
return ""
def iter_skills(root: Path) -> list[Path]:
skills: list[Path] = []
if not root.is_dir():
return skills
for entry in sorted(root.iterdir()):
if not entry.is_dir() or entry.name.startswith("_"):
continue
if (entry / "SKILL.md").is_file():
skills.append(entry)
return skills
def catalog_rows(root: Path) -> list[tuple[str, str, str]]:
rows: list[tuple[str, str, str]] = []
errors: list[str] = []
for skill in iter_skills(root):
readme_path = skill / "README.md"
if not readme_path.is_file():
errors.append(f"{skill.name}: 缺少 README.md")
continue
text = readme_path.read_text(encoding="utf-8")
lede = _lede(text)
example = _first_example(text)
if not lede:
errors.append(f"{skill.name}: README 缺少一句话说明")
if not example:
errors.append(f"{skill.name}: README 缺少可复制的使用示例")
if lede and example:
rows.append((skill.name, lede, example))
if errors:
raise SystemExit("catalog 失败:\n " + "\n ".join(errors))
return rows
def format_table(rows: list[tuple[str, str, str]]) -> str:
lines = [
"| Skill | 做什么 | 可以这么说 |",
"| --- | --- | --- |",
]
for name, lede, example in rows:
example = example.replace("|", "\\|")
lede = lede.replace("|", "\\|")
lines.append(f"| {name} | {lede} | `{example}` |")
return "\n".join(lines) + "\n"
def main() -> int:
parser = argparse.ArgumentParser(
description="列出 builtin skill 的一句话说明和可复制示例"
)
parser.add_argument(
"--skills-dir",
type=Path,
default=None,
help="skill 根目录(默认本仓库 skills/ 或 ~/.pouch/skills)",
)
args = parser.parse_args()
root = args.skills_dir.resolve() if args.skills_dir else skills_dir()
if not root.is_dir():
print(f"找不到 skills 目录: {root}", file=sys.stderr)
return 2
sys.stdout.write(format_table(catalog_rows(root)))
return 0
if __name__ == "__main__":
raise SystemExit(main())
+144
View File
@@ -0,0 +1,144 @@
#!/usr/bin/env python3
"""Suggest pouch skills from well-known files in a project root."""
from __future__ import annotations
import argparse
import sys
from pathlib import Path
INIT = {
"ack": "说「/ack 初始化」或 `pouch init ack`;不要静默初始化",
"builder": "说「用 builder 初始化」;不要 `pouch init builder`",
"deployer": "说「用 deployer 初始化」;不要 `pouch init deployer`",
}
FILE_SIGNALS: dict[str, tuple[str, ...]] = {
"ack": (".pouch/ack",),
"builder": (
"makefile.builder",
"debian",
"Dockerfile",
"Containerfile",
".env.builder",
),
"deployer": (
".pouch/deployer",
"compose.yaml",
"compose.yml",
"docker-compose.yaml",
"docker-compose.yml",
"argocd.yaml",
"k8s",
"helm",
),
"manage-release": ("CHANGELOG.md", "CHANGELOG", "VERSION"),
"declarative-openspec-loop": ("openspec", ".openspec"),
"discussion-notes": ("000inbox",),
}
INITED_MARKERS = {
"ack": (".pouch/ack",),
"builder": ("makefile.builder",),
"deployer": (".pouch/deployer",),
}
def project_root(start: Path) -> Path:
cur = start.resolve()
if cur.is_file():
cur = cur.parent
for candidate in (cur, *cur.parents):
if (candidate / ".git").exists():
return candidate
return cur
def is_pouch_repo(root: Path) -> bool:
return (
(root / "install.sh").is_file()
and (root / "bin" / "pouch").is_file()
and (root / "skills" / "pouch" / "SKILL.md").is_file()
)
def existing(root: Path, rel: str) -> bool:
return (root / rel).exists()
def hits(root: Path, names: tuple[str, ...]) -> list[str]:
return [name for name in names if existing(root, name)]
def suggest(root: Path) -> dict[str, object]:
suggested: list[tuple[str, list[str]]] = []
already: list[tuple[str, list[str]]] = []
init: list[tuple[str, str]] = []
for name, names in FILE_SIGNALS.items():
found = hits(root, names)
if not found:
continue
inited = hits(root, INITED_MARKERS.get(name, ()))
if inited:
already.append((name, inited))
else:
suggested.append((name, found))
if name in INIT and not inited:
init.append((name, INIT[name]))
return {
"root": str(root),
"this_is_pouch_repo": is_pouch_repo(root),
"suggested": suggested,
"already": already,
"init": init,
}
def format_report(data: dict[str, object]) -> str:
lines = [f"project: {data['root']}"]
if data["this_is_pouch_repo"]:
lines.append("this_is_pouch_repo: true")
lines.append("不要按业务项目接入。维护 skill 走 pouch 的创建或回流模式。")
return "\n".join(lines) + "\n"
lines.append("this_is_pouch_repo: false")
suggested = data["suggested"]
already = data["already"]
init = data["init"]
if suggested:
lines.append("suggested:")
for name, why in suggested:
lines.append(f" {name}\twhy: {', '.join(why)}")
else:
lines.append("suggested: (none)")
if already:
lines.append("already:")
for name, why in already:
lines.append(f" {name}\t{', '.join(why)}")
else:
lines.append("already: (none)")
if init:
lines.append("init:")
for name, how in init:
lines.append(f" {name}\t{how}")
else:
lines.append("init: (none)")
return "\n".join(lines) + "\n"
def main() -> int:
parser = argparse.ArgumentParser(description="根据仓库信号建议 pouch skill")
parser.add_argument(
"path",
nargs="?",
default=".",
help="项目目录(默认 cwd;向上找 .git)",
)
args = parser.parse_args()
root = project_root(Path(args.path))
sys.stdout.write(format_report(suggest(root)))
return 0
if __name__ == "__main__":
raise SystemExit(main())
+231
View File
@@ -102,6 +102,16 @@ class BuilderCheckTests(unittest.TestCase):
self.assertIn("makefile.builder", contract)
self.assertIn(".env.builder", contract)
self.assertIn("不要读取或改写用户 `.env`", contract)
self.assertIn("ARCHES", contract)
readme = (REPO_ROOT / "skills" / "builder" / "README.md").read_text(
encoding="utf-8"
)
self.assertIn("ARCHES", readme)
self.assertIn("amd64 arm64", readme)
self.assertIn("多架构", readme)
self.assertIn("copy_artifact.sh", skill)
self.assertIn("android", skill.lower())
self.assertIn("COPY_DEST", contract)
def test_no_makefile_without_ready_is_usage_error(self) -> None:
with tempfile.TemporaryDirectory() as temp:
@@ -233,6 +243,227 @@ class BuilderCheckTests(unittest.TestCase):
self.assertNotIn("hijacked", text)
self.assertNotIn(":latest", text)
def test_default_template_rejects_arch_outside_arches(self) -> None:
with tempfile.TemporaryDirectory() as temp:
project = Path(temp)
init_repo(project)
write_contract_makefile(project)
result = subprocess.run(
["make", "-C", str(project), "-f", "makefile.builder", "-n", "build", "ARCH=arm64"],
capture_output=True,
text=True,
check=False,
)
self.assertNotEqual(result.returncode, 0, result.stderr)
self.assertIn("amd64", result.stderr)
def test_multi_arches_deb_and_push_fan_out(self) -> None:
with tempfile.TemporaryDirectory() as temp:
project = Path(temp)
init_repo(project)
write_contract_makefile(project)
makefile = project / "makefile.builder"
makefile.write_text(
makefile.read_text(encoding="utf-8").replace(
"ARCHES ?= amd64", "ARCHES ?= amd64 arm64"
),
encoding="utf-8",
)
code, text = run_check(
project, "--ready", which={"dpkg-deb": "/usr/bin/dpkg-deb"}
)
self.assertEqual(code, 0, text)
self.assertIn("ARCHES: 'amd64 arm64'", text)
deb = subprocess.run(
["make", "-C", str(project), "-f", "makefile.builder", "-n", "deb"],
capture_output=True,
text=True,
check=False,
)
self.assertEqual(deb.returncode, 0, deb.stderr)
combined = deb.stdout + deb.stderr
self.assertIn("ARCH=amd64", combined)
self.assertIn("ARCH=arm64", combined)
pinned = subprocess.run(
[
"make",
"-C",
str(project),
"-f",
"makefile.builder",
"-n",
"deb",
"ARCH=arm64",
],
capture_output=True,
text=True,
check=False,
)
self.assertEqual(pinned.returncode, 0, pinned.stderr)
self.assertIn("_arm64.deb", pinned.stdout)
self.assertNotIn("_amd64.deb", pinned.stdout)
def test_legacy_makefile_without_arches_still_passes(self) -> None:
with tempfile.TemporaryDirectory() as temp:
project = Path(temp)
init_repo(project)
(project / "makefile.builder").write_text(
"\n".join(
[
"ARCH ?= amd64",
"ifneq ($(filter $(ARCH),amd64 arm64),$(ARCH))",
"$(error ARCH must be amd64 or arm64)",
"endif",
"PROJECT_NAME ?= $(notdir $(CURDIR))",
"DIST_DIR ?= dist",
f"include {SCRIPTS / 'version.mk'}",
f"BUILDER_SCRIPTS := {SCRIPTS}",
".PHONY: help version clean build deb push",
"help:",
"\t@echo $(PROJECT_NAME) $(VERSION)",
"version:",
"\t@echo $(VERSION)",
"clean:",
"\trm -rf $(DIST_DIR)",
"build:",
"\tmkdir -p $(DIST_DIR)",
"\t@echo build linux/$(ARCH)",
"deb: build",
"\tmkdir -p $(DIST_DIR)",
"\tdpkg-deb --build packaging $(DIST_DIR)/$(PROJECT_NAME)_$(VERSION)_$(ARCH).deb",
"push:",
"\t$(BUILDER_SCRIPTS)/upload_deb.sh $(DIST_DIR)/$(PROJECT_NAME)_$(VERSION)_$(ARCH).deb",
"",
]
),
encoding="utf-8",
)
code, text = run_check(
project, "--ready", which={"dpkg-deb": "/usr/bin/dpkg-deb"}
)
self.assertEqual(code, 0, text)
self.assertIn("arches target: absent", text)
result = subprocess.run(
["make", "-C", str(project), "-f", "makefile.builder", "-n", "build", "ARCH=arm64"],
capture_output=True,
text=True,
check=False,
)
self.assertEqual(result.returncode, 0, result.stderr)
def test_publish_docker_derives_platforms_from_arches(self) -> None:
script = SCRIPTS / "publish_docker.sh"
with tempfile.TemporaryDirectory() as temp:
project = Path(temp)
init_repo(project)
write_contract_makefile(project)
makefile = project / "makefile.builder"
makefile.write_text(
makefile.read_text(encoding="utf-8").replace(
"ARCHES ?= amd64", "ARCHES ?= amd64 arm64"
),
encoding="utf-8",
)
(project / "Dockerfile").write_text("FROM scratch\n", encoding="utf-8")
env = os.environ.copy()
env["BUILDER_SKILL_DIR"] = str(SCRIPTS.parent)
env.pop("PLATFORMS", None)
derived = subprocess.run(
[
str(script),
"--registry",
"example.com",
"--repository",
"ns/foo",
"--tag",
"dry-run-tag",
"--dry-run",
],
cwd=project,
capture_output=True,
text=True,
check=False,
env=env,
)
self.assertEqual(derived.returncode, 0, derived.stdout + derived.stderr)
self.assertIn("Platform: linux/amd64,linux/arm64", derived.stdout)
env["PLATFORMS"] = "linux/amd64"
overridden = subprocess.run(
[
str(script),
"--registry",
"example.com",
"--repository",
"ns/foo",
"--tag",
"dry-run-tag",
"--dry-run",
],
cwd=project,
capture_output=True,
text=True,
check=False,
env=env,
)
self.assertEqual(overridden.returncode, 0, overridden.stdout + overridden.stderr)
self.assertIn("Platform: linux/amd64", overridden.stdout)
self.assertNotIn("linux/arm64", overridden.stdout)
def test_android_copy_makefile_skips_missing_copy_dest(self) -> None:
template = REPO_ROOT / "skills" / "builder" / "templates" / "makefile.android.builder"
with tempfile.TemporaryDirectory() as temp:
project = Path(temp)
init_repo(project)
text = template.read_text(encoding="utf-8")
text = text.replace(
"include $(HOME)/.pouch/skills/builder/scripts/version.mk",
f"include {SCRIPTS / 'version.mk'}",
)
(project / "makefile.builder").write_text(text, encoding="utf-8")
code, out = run_check(project, "--ready")
self.assertEqual(code, 0, out)
self.assertIn("[PASS] 12. android 目标产物形状与纯构建", out)
self.assertIn("[SKIP] 13. ios 目标产物形状与纯构建", out)
self.assertIn("COPY_DEST: MISSING", out)
self.assertIn("blocks publish, not build", out)
def test_inline_docker_cp_in_push_fails(self) -> None:
with tempfile.TemporaryDirectory() as temp:
project = Path(temp)
init_repo(project)
(project / "makefile.builder").write_text(
"\n".join(
[
"ARCHES ?= amd64",
"ARCH ?= amd64",
"ifneq ($(filter $(ARCH),$(ARCHES)),$(ARCH))",
"$(error ARCH must be one of: $(ARCHES))",
"endif",
"DIST_DIR ?= dist",
f"include {SCRIPTS / 'version.mk'}",
".PHONY: help version arches clean build push",
"help:",
"\t@echo app $(VERSION)",
"version:",
"\t@echo $(VERSION)",
"arches:",
"\t@echo $(ARCHES)",
"clean:",
"\trm -rf $(DIST_DIR)",
"build:",
"\tmkdir -p $(DIST_DIR)/$(ARCH).apk",
"push:",
"\tdocker cp dist/app.apk file-server:/srv/app.apk",
"",
]
),
encoding="utf-8",
)
code, out = run_check(project, "--ready")
self.assertEqual(code, 1, out)
self.assertIn("[FAIL] 7. push 仅委托 builder 脚本", out)
self.assertIn("docker cp", out)
if __name__ == "__main__":
unittest.main()
+119
View File
@@ -0,0 +1,119 @@
from __future__ import annotations
import os
import subprocess
import tempfile
import unittest
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
SCRIPT = REPO_ROOT / "skills" / "builder" / "scripts" / "copy_artifact.sh"
def git(cwd: Path, *args: str) -> None:
result = subprocess.run(
["git", *args],
cwd=cwd,
text=True,
capture_output=True,
check=False,
env={
**os.environ,
"GIT_AUTHOR_NAME": "Test",
"GIT_AUTHOR_EMAIL": "test@example.com",
"GIT_COMMITTER_NAME": "Test",
"GIT_COMMITTER_EMAIL": "test@example.com",
},
)
if result.returncode != 0:
raise AssertionError(f"git {args} failed: {result.stderr}")
class CopyArtifactTests(unittest.TestCase):
def test_local_copy_and_alias(self) -> None:
with tempfile.TemporaryDirectory() as temp:
root = Path(temp)
git(root, "init", "-b", "main")
git(root, "config", "user.email", "test@example.com")
git(root, "config", "user.name", "Test")
src = root / "app.apk"
src.write_bytes(b"apk")
git(root, "add", "app.apk")
git(root, "commit", "-m", "apk")
dest = root / "out"
result = subprocess.run(
[str(SCRIPT), "-d", str(dest), "-a", "latest.apk", str(src)],
cwd=root,
capture_output=True,
text=True,
check=False,
)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertTrue((dest / "app.apk").is_file())
self.assertTrue((dest / "latest.apk").is_file())
self.assertEqual((dest / "latest.apk").read_bytes(), b"apk")
self.assertIn("copy_artifact: 1 file(s)", result.stdout)
def test_dirty_tree_requires_allow(self) -> None:
with tempfile.TemporaryDirectory() as temp:
root = Path(temp)
git(root, "init", "-b", "main")
git(root, "config", "user.email", "test@example.com")
git(root, "config", "user.name", "Test")
src = root / "app.apk"
src.write_bytes(b"apk")
git(root, "add", "app.apk")
git(root, "commit", "-m", "apk")
src.write_bytes(b"changed")
dest = root / "out"
blocked = subprocess.run(
[str(SCRIPT), "-d", str(dest), str(src)],
cwd=root,
capture_output=True,
text=True,
check=False,
)
self.assertEqual(blocked.returncode, 3, blocked.stderr)
allowed = subprocess.run(
[str(SCRIPT), "-d", str(dest), str(src)],
cwd=root,
capture_output=True,
text=True,
check=False,
env={**os.environ, "ALLOW_UNCOMMITTED": "1"},
)
self.assertEqual(allowed.returncode, 0, allowed.stdout + allowed.stderr)
def test_dry_run_docker_dest_prints_docker_cp(self) -> None:
with tempfile.TemporaryDirectory() as temp:
root = Path(temp)
git(root, "init", "-b", "main")
git(root, "config", "user.email", "test@example.com")
git(root, "config", "user.name", "Test")
src = root / "app.apk"
src.write_bytes(b"apk")
git(root, "add", "app.apk")
git(root, "commit", "-m", "apk")
result = subprocess.run(
[
str(SCRIPT),
"--dry-run",
"-d",
"file-server:/srv",
"-a",
"latest.apk",
str(src),
],
cwd=root,
capture_output=True,
text=True,
check=False,
)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertIn("docker cp", result.stdout)
self.assertIn("file-server:/srv/app.apk", result.stdout)
self.assertIn("file-server:/srv/latest.apk", result.stdout)
if __name__ == "__main__":
unittest.main()
+60 -5
View File
@@ -14,6 +14,10 @@ VERSION_SH = SCRIPTS / "version.sh"
PUBLISH_DOCKER = SCRIPTS / "publish_docker.sh"
VERSION_MK = SCRIPTS / "version.mk"
# Frozen build date so test versions are deterministic.
SOURCE_DATE_EPOCH = "1704067200" # 2024-01-01T00:00:00Z
BUILD_DATE = "20240101"
def git(cwd: Path, *args: str, env: dict[str, str] | None = None) -> str:
merged = os.environ.copy()
@@ -48,6 +52,7 @@ def sha7(cwd: Path) -> str:
def version_sh(*args: str, cwd: Path, env: dict[str, str] | None = None) -> subprocess.CompletedProcess[str]:
merged = os.environ.copy()
merged["SOURCE_DATE_EPOCH"] = SOURCE_DATE_EPOCH
if env:
merged.update(env)
return subprocess.run(
@@ -86,6 +91,29 @@ class BuilderVersionTests(unittest.TestCase):
self.assertEqual(docker.returncode, 0, docker.stderr)
self.assertEqual(docker.stdout, "1.4.2\n")
def test_post_release_commit_bumps_patch(self) -> None:
with tempfile.TemporaryDirectory() as temp:
repo = Path(temp) / "repo"
repo.mkdir()
init_repo(repo)
(repo / "a").write_text("1\n", encoding="utf-8")
commit(repo, "release")
git(repo, "tag", "-a", "v1.2.0", "-m", "1.2.0")
(repo / "b").write_text("fix\n", encoding="utf-8")
commit(repo, "bugfix")
sha = sha7(repo)
result = version_sh(cwd=repo)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(
result.stdout.strip(),
f"1.2.1~{BUILD_DATE}.main.1+g{sha}",
)
docker = version_sh("--docker", cwd=repo)
self.assertEqual(
docker.stdout.strip(),
f"1.2.1-{BUILD_DATE}.main.1.g{sha}",
)
def test_test_version_uses_ancestor_not_global_max(self) -> None:
with tempfile.TemporaryDirectory() as temp:
root = Path(temp)
@@ -113,12 +141,12 @@ class BuilderVersionTests(unittest.TestCase):
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(
result.stdout.strip(),
f"1.4.2~feat-login-v2.2+g{feature_sha}",
f"1.4.3~{BUILD_DATE}.feat-login-v2.2+g{feature_sha}",
)
docker = version_sh("--docker", cwd=repo)
self.assertEqual(
docker.stdout.strip(),
f"1.4.2-feat-login-v2.2.g{feature_sha}",
f"1.4.3-{BUILD_DATE}.feat-login-v2.2.g{feature_sha}",
)
def test_ignores_prerelease_and_app_tags(self) -> None:
@@ -136,7 +164,7 @@ class BuilderVersionTests(unittest.TestCase):
sha = sha7(repo)
result = version_sh(cwd=repo)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(result.stdout.strip(), f"1.0.0~main.1+g{sha}")
self.assertEqual(result.stdout.strip(), f"1.0.1~{BUILD_DATE}.main.1+g{sha}")
def test_no_stable_tag(self) -> None:
with tempfile.TemporaryDirectory() as temp:
@@ -148,7 +176,7 @@ class BuilderVersionTests(unittest.TestCase):
sha = sha7(repo)
result = version_sh(cwd=repo)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(result.stdout.strip(), f"0.0.0~main+g{sha}")
self.assertEqual(result.stdout.strip(), f"0.0.0~{BUILD_DATE}.main+g{sha}")
def test_detached_uses_env_branch(self) -> None:
with tempfile.TemporaryDirectory() as temp:
@@ -164,7 +192,34 @@ class BuilderVersionTests(unittest.TestCase):
git(repo, "checkout", "--detach", "HEAD")
result = version_sh(cwd=repo, env={"BUILD_BRANCH": "ci_job"})
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(result.stdout.strip(), f"0.1.0~ci-job.1+g{sha}")
self.assertEqual(result.stdout.strip(), f"0.1.1~{BUILD_DATE}.ci-job.1+g{sha}")
def test_source_date_epoch_changes_test_date(self) -> None:
with tempfile.TemporaryDirectory() as temp:
repo = Path(temp) / "repo"
repo.mkdir()
init_repo(repo)
(repo / "a").write_text("1\n", encoding="utf-8")
commit(repo, "init")
git(repo, "tag", "v1.0.0")
(repo / "b").write_text("2\n", encoding="utf-8")
commit(repo, "next")
sha = sha7(repo)
later = version_sh(cwd=repo, env={"SOURCE_DATE_EPOCH": "1725148800"})
self.assertEqual(later.returncode, 0, later.stderr)
self.assertEqual(later.stdout.strip(), f"1.0.1~20240901.main.1+g{sha}")
def test_official_version_ignores_build_date(self) -> None:
with tempfile.TemporaryDirectory() as temp:
repo = Path(temp) / "repo"
repo.mkdir()
init_repo(repo)
(repo / "a").write_text("1\n", encoding="utf-8")
commit(repo, "init")
git(repo, "tag", "-a", "v1.4.2", "-m", "1.4.2")
result = version_sh(cwd=repo, env={"SOURCE_DATE_EPOCH": "1725148800"})
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(result.stdout.strip(), "1.4.2")
def test_override_official_rejected_off_tag(self) -> None:
with tempfile.TemporaryDirectory() as temp:
+43 -2
View File
@@ -2,6 +2,8 @@ from __future__ import annotations
import importlib.util
import os
import subprocess
import sys
import tempfile
import unittest
from io import StringIO
@@ -10,8 +12,10 @@ from unittest import mock
REPO_ROOT = Path(__file__).resolve().parents[1]
CHECK_PY = REPO_ROOT / "skills" / "deployer" / "scripts" / "deploy" / "check.py"
LIB_PY = REPO_ROOT / "skills" / "deployer" / "scripts" / "deploy" / "lib.py"
DEPLOY_SCRIPTS = REPO_ROOT / "skills" / "deployer" / "scripts" / "deploy"
CHECK_PY = DEPLOY_SCRIPTS / "check.py"
LIST_PY = DEPLOY_SCRIPTS / "list.py"
LIB_PY = DEPLOY_SCRIPTS / "lib.py"
lib_spec = importlib.util.spec_from_file_location("deployer_lib", LIB_PY)
assert lib_spec is not None and lib_spec.loader is not None
@@ -55,6 +59,43 @@ class DeployerCheckTests(unittest.TestCase):
self.assertIn("scripts/deploy/check.py", skill)
self.assertIn("不要写假 node", skill)
def test_skill_documents_redeploy_happy_path(self) -> None:
skill = (REPO_ROOT / "skills" / "deployer" / "SKILL.md").read_text(
encoding="utf-8"
)
compose_section = skill.split("## Compose 操作", 1)[1].split("## Argo CD", 1)[0]
self.assertIn("不要读 compose.md", compose_section)
self.assertIn("remote.py <svc> recreate", compose_section)
self.assertIn("一句问", compose_section)
self.assertIn("没有 `test` 就用非 `prod` 的那个", compose_section)
self.assertIn("不要** `sync.py` / `remote.py`", compose_section)
self.assertIn("docker compose up -d --force-recreate", compose_section)
self.assertNotIn(
"读 [compose.md](references/compose.md)。用 `list.py` 摸底",
compose_section,
)
def test_list_py_isolated_mode_imports_lib(self) -> None:
with tempfile.TemporaryDirectory() as temp:
project = Path(temp)
root = project / ".pouch" / "deployer"
env = root / "test"
env.mkdir(parents=True)
(root / "_config.yaml").write_text("node: my-vps\n", encoding="utf-8")
(env / "compose.yaml").write_text(
"services:\n web:\n image: nginx\n", encoding="utf-8"
)
proc = subprocess.run(
[sys.executable, "-I", "-S", str(LIST_PY)],
cwd=str(project),
capture_output=True,
text=True,
check=False,
)
self.assertEqual(proc.returncode, 0, proc.stderr + proc.stdout)
self.assertIn("test", proc.stdout)
self.assertNotIn("No module named 'lib'", proc.stderr)
def test_missing_layout_fails(self) -> None:
with tempfile.TemporaryDirectory() as temp:
project = Path(temp)
+149
View File
@@ -0,0 +1,149 @@
from __future__ import annotations
import importlib.util
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
from unittest import mock
REPO_ROOT = Path(__file__).resolve().parents[1]
DEPLOY_SCRIPTS = REPO_ROOT / "skills" / "deployer" / "scripts" / "deploy"
SYNC_PY = DEPLOY_SCRIPTS / "sync.py"
if str(DEPLOY_SCRIPTS) not in sys.path:
sys.path.insert(0, str(DEPLOY_SCRIPTS))
sync_spec = importlib.util.spec_from_file_location("deployer_sync", SYNC_PY)
assert sync_spec is not None and sync_spec.loader is not None
deployer_sync = importlib.util.module_from_spec(sync_spec)
sync_spec.loader.exec_module(deployer_sync)
def _info(service_dir: str, **overrides) -> dict:
info = {
"service_dir": service_dir,
"node": "edge",
"remote_dir": "/opt/app/svc",
"sync_exclude": ["data", "_data"],
"port": None,
"identity_file": None,
}
info.update(overrides)
return info
class DeployerSyncTests(unittest.TestCase):
def test_collect_sync_files_skips_excluded_directories(self) -> None:
with tempfile.TemporaryDirectory() as temp:
src = Path(temp)
(src / "compose.yaml").write_text("ok\n", encoding="utf-8")
(src / "data").mkdir()
(src / "data" / "db").write_text("nope\n", encoding="utf-8")
nested = src / "cfg"
nested.mkdir()
(nested / "app.env").write_text("x\n", encoding="utf-8")
(nested / "data").mkdir()
(nested / "data" / "cache").write_text("nope\n", encoding="utf-8")
files = {
p.relative_to(src).as_posix()
for p in deployer_sync.collect_sync_files(str(src), ["data", "_data"])
}
self.assertEqual(files, {"compose.yaml", "cfg/app.env"})
def test_prefers_rsync_when_available(self) -> None:
info = _info("/tmp/svc")
completed = subprocess.CompletedProcess(["rsync"], 0)
with mock.patch.object(deployer_sync.shutil, "which", return_value="/usr/bin/rsync"):
with mock.patch.object(
deployer_sync.subprocess, "run", return_value=completed
) as run:
rc = deployer_sync.sync_directory(info)
self.assertEqual(rc, 0)
cmd = run.call_args[0][0]
self.assertEqual(cmd[0], "rsync")
self.assertIn("--delete", cmd)
self.assertIn("--exclude=data/", cmd)
def test_falls_back_to_scp_when_rsync_missing(self) -> None:
with tempfile.TemporaryDirectory() as temp:
src = Path(temp)
(src / "compose.yaml").write_text("ok\n", encoding="utf-8")
(src / "data").mkdir()
(src / "data" / "db").write_text("nope\n", encoding="utf-8")
nested = src / "cfg"
nested.mkdir()
(nested / "app.env").write_text("x\n", encoding="utf-8")
commands: list[list[str]] = []
def fake_run(cmd, **kwargs):
commands.append(list(cmd))
return subprocess.CompletedProcess(cmd, 0)
info = _info(str(src), port=2222, identity_file="/tmp/key")
with mock.patch.object(deployer_sync.shutil, "which", return_value=None):
with mock.patch.object(
deployer_sync.subprocess, "run", side_effect=fake_run
):
rc = deployer_sync.sync_directory(info)
self.assertEqual(rc, 0)
self.assertTrue(commands)
mkdir_cmd = commands[0]
self.assertEqual(mkdir_cmd[0], "ssh")
self.assertIn("-p", mkdir_cmd)
self.assertIn("2222", mkdir_cmd)
self.assertTrue(any("/opt/app/svc/cfg" in part for part in mkdir_cmd))
scp_cmds = [c for c in commands if c and c[0] == "scp"]
self.assertEqual(len(scp_cmds), 2)
joined = "\n".join(" ".join(c) for c in scp_cmds)
self.assertIn("compose.yaml", joined)
self.assertIn("app.env", joined)
self.assertNotIn("db", joined)
self.assertNotIn("--delete", joined)
self.assertIn("-P", scp_cmds[0])
self.assertIn("2222", scp_cmds[0])
self.assertIn("-i", scp_cmds[0])
self.assertIn("/tmp/key", scp_cmds[0])
def test_rsync_filenotfound_falls_back_to_scp(self) -> None:
with tempfile.TemporaryDirectory() as temp:
src = Path(temp)
(src / "compose.yaml").write_text("ok\n", encoding="utf-8")
commands: list[list[str]] = []
def fake_run(cmd, **kwargs):
if cmd and cmd[0] == "rsync":
raise FileNotFoundError("rsync")
commands.append(list(cmd))
return subprocess.CompletedProcess(cmd, 0)
with mock.patch.object(
deployer_sync.shutil, "which", return_value="/usr/bin/rsync"
):
with mock.patch.object(
deployer_sync.subprocess, "run", side_effect=fake_run
):
rc = deployer_sync.sync_directory(_info(str(src)))
self.assertEqual(rc, 0)
self.assertTrue(any(c and c[0] == "scp" for c in commands))
def test_docs_say_scp_fallback_not_tar(self) -> None:
compose = (
REPO_ROOT / "skills" / "deployer" / "references" / "compose.md"
).read_text(encoding="utf-8")
check = (DEPLOY_SCRIPTS / "check.py").read_text(encoding="utf-8")
self.assertIn("本机无 rsync 时 scp 兜底", compose)
self.assertNotIn("本机无 rsync 时 tar over SSH 兜底", compose)
self.assertIn("scp fallback", check)
self.assertNotIn("tar-over-SSH", check)
if __name__ == "__main__":
unittest.main()
+123
View File
@@ -0,0 +1,123 @@
from __future__ import annotations
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
CATALOG = REPO_ROOT / "skills" / "pouch" / "scripts" / "catalog.py"
SUGGEST = REPO_ROOT / "skills" / "pouch" / "scripts" / "suggest_skills.py"
def _run(script: Path, *args: str) -> subprocess.CompletedProcess[str]:
return subprocess.run(
[sys.executable, str(script), *args],
check=False,
capture_output=True,
text=True,
)
class CatalogTests(unittest.TestCase):
def test_repo_skills_have_lede_and_example(self) -> None:
result = _run(CATALOG, "--skills-dir", str(REPO_ROOT / "skills"))
self.assertEqual(result.returncode, 0, result.stderr)
for name in (
"ack",
"builder",
"deployer",
"host-sec-check",
"manage-release",
"note-style",
"pouch",
):
self.assertIn(f"| {name} |", result.stdout)
self.assertIn("/ack 初始化", result.stdout)
self.assertIn("用 builder 初始化这个项目", result.stdout)
self.assertNotIn("触发词", result.stdout)
def test_missing_readme_fails(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
skill = Path(tmp) / "demo"
skill.mkdir()
(skill / "SKILL.md").write_text(
"---\nname: demo\ndescription: d\n---\n# demo\n",
encoding="utf-8",
)
result = _run(CATALOG, "--skills-dir", tmp)
self.assertEqual(result.returncode, 1)
self.assertIn("缺少 README.md", result.stderr)
def test_extracts_first_sentence_and_skips_hash_comments(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
skill = Path(tmp) / "demo"
skill.mkdir()
(skill / "SKILL.md").write_text(
"---\nname: demo\ndescription: d\n---\n# demo\n",
encoding="utf-8",
)
(skill / "README.md").write_text(
"# demo\n\n"
"这是一句话说明。后面还有一句不该进短清单。\n\n"
"## 使用示例\n\n"
"```text\n"
"# 注释\n"
"用 demo 做这件事。\n"
"```\n",
encoding="utf-8",
)
result = _run(CATALOG, "--skills-dir", tmp)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertIn("这是一句话说明。", result.stdout)
self.assertNotIn("不该进短清单", result.stdout)
self.assertIn("用 demo 做这件事。", result.stdout)
class SuggestSkillsTests(unittest.TestCase):
def test_pouch_repo_is_not_an_app(self) -> None:
result = _run(SUGGEST, str(REPO_ROOT))
self.assertEqual(result.returncode, 0, result.stderr)
self.assertIn("this_is_pouch_repo: true", result.stdout)
self.assertNotIn("suggested:", result.stdout)
def test_dockerfile_suggests_builder(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
(root / "Dockerfile").write_text("FROM scratch\n", encoding="utf-8")
result = _run(SUGGEST, str(root))
self.assertEqual(result.returncode, 0, result.stderr)
self.assertIn("this_is_pouch_repo: false", result.stdout)
self.assertIn("builder", result.stdout)
self.assertIn("Dockerfile", result.stdout)
self.assertIn("用 builder 初始化", result.stdout)
self.assertIn("不要 `pouch init builder`", result.stdout)
def test_existing_makefile_builder_is_already(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
(root / "makefile.builder").write_text("build:\n", encoding="utf-8")
result = _run(SUGGEST, str(root))
self.assertEqual(result.returncode, 0, result.stderr)
self.assertIn("already:", result.stdout)
self.assertIn("builder", result.stdout)
self.assertIn("init: (none)", result.stdout)
def test_compose_and_ack_dir(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
(root / "compose.yaml").write_text("services: {}\n", encoding="utf-8")
(root / ".pouch" / "ack").mkdir(parents=True)
result = _run(SUGGEST, str(root))
self.assertEqual(result.returncode, 0, result.stderr)
self.assertIn("deployer", result.stdout)
self.assertIn("compose.yaml", result.stdout)
self.assertIn("ack", result.stdout)
self.assertIn(".pouch/ack", result.stdout)
self.assertIn("用 deployer 初始化", result.stdout)
self.assertNotIn("/ack 初始化", result.stdout)
if __name__ == "__main__":
unittest.main()
+3
View File
@@ -21,6 +21,9 @@ class ProjectSkillContentTests(unittest.TestCase):
"第三方 skill",
"pouch init ack",
"显式调用全局 `/ack` skill",
"references/install.md",
"scripts/catalog.py",
"references/onboard.md",
):
self.assertIn(expected, content)