From c2d6a5a0d78b94baa0add89121b274232978627d Mon Sep 17 00:00:00 2001 From: laily Date: Fri, 4 Sep 2026 00:07:15 +0800 Subject: [PATCH 01/10] feat(deployer): skip sync on localhost and complete skill READMEs Redeploy locally with docker compose when the node is 127.0.0.1 and the image already exists. Make list/sync/remote/deb importable under python3 -I -S. Add human README.md for every builtin skill, slim ack's user-facing docs, and drop unused OpenSpec config. --- .gitignore | 1 + AGENTS.md | 64 ++--- README.md | 120 ++++++-- openspec/config.yaml | 20 -- pouch/README.md | 3 +- skills/ack/README.md | 319 ++++----------------- skills/builder/README.md | 33 +++ skills/declarative-openspec-loop/README.md | 55 ++++ skills/deployer/README.md | 18 +- skills/deployer/SKILL.md | 19 +- skills/deployer/references/compose.md | 4 +- skills/deployer/scripts/deploy/deb.py | 6 +- skills/deployer/scripts/deploy/list.py | 7 +- skills/deployer/scripts/deploy/remote.py | 7 +- skills/deployer/scripts/deploy/sync.py | 7 +- skills/discussion-notes/README.md | 57 ++++ skills/draw-prototype-flow/README.md | 9 +- skills/manage-release/README.md | 7 + skills/memory-distill/README.md | 7 + skills/orc/README.md | 9 + skills/pouch/README.md | 72 +++-- tests/test_deployer_check.py | 45 ++- 22 files changed, 517 insertions(+), 372 deletions(-) delete mode 100644 openspec/config.yaml create mode 100644 skills/declarative-openspec-loop/README.md create mode 100644 skills/discussion-notes/README.md diff --git a/.gitignore b/.gitignore index 8deca55..bf9c198 100644 --- a/.gitignore +++ b/.gitignore @@ -31,3 +31,4 @@ Thumbs.db # 本地环境配置(勿提交密钥) .env .env.* +.pouch diff --git a/AGENTS.md b/AGENTS.md index 3eebfbf..0d0d10d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,16 +12,14 @@ ## 快速开始 +需要 Python 3、git,以及 Cursor / Claude Code / Codex 或其它兼容 Agent Skills 的 Agent。人类用法见根 [README.md](README.md) 和各 skill 的 `README.md`。用户要代装时按 README 的 [让 Agent 代装](README.md#让-agent-代装) 执行,不要只用 `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,19 +29,11 @@ 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 -pouch/ # CLI 源码(Python 3) -bin/pouch # CLI 入口 -catalog.yaml # pouch 预置 Skill 来源目录 -AGENTS.md # 本文档 +skills/ # 自研 skill(SSOT):每个子目录含 SKILL.md(Agent)和 README.md(人类) +pouch/ # CLI 源码(Python 3) +bin/pouch # CLI 入口 +catalog.yaml # pouch 预置 Skill 来源目录 +AGENTS.md # 本文档 ``` **本仓库包含**:`skills/`、`pouch/`、`bin/pouch`、`catalog.yaml`、`AGENTS.md` @@ -56,16 +46,21 @@ AGENTS.md # 本文档 ### 内置(Builtin) -| 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 | +| 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 低保真原型和用户动线 | +| [manage-release](skills/manage-release/README.md) | 给已准备好的 commit 发版本:更新说明、tag、可选 Forge Release | +| [memory-distill](skills/memory-distill/README.md) | 任务结束后把可复用结论写入通用记忆和项目记忆 | +| [orc](skills/orc/README.md) | 把开发、发版、DEB / Docker 意图编排成阶段,按档位分发给对应 skill | +| [pouch](skills/pouch/README.md) | 创建、校验、安装和维护 builtin skill | -新建 skill:复制 `skills/_template/` → `skills//`,编辑 `SKILL.md`,在本仓库 commit。 +新建 skill:优先 `pouch create`;或复制 `skills/_template/` → `skills//`,编辑 `SKILL.md` 和 `README.md`,在本仓库 commit。 Skill 需要的稳定规范、模板、示例和脚本直接放在自己的目录中,例如 `skills/ack/references/`、`templates/`、`examples/` 和 `scripts/`。项目初始化只生成 @@ -164,12 +159,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 +249,8 @@ pouch sync ``` skill-name/ -├── SKILL.md # 必需 +├── SKILL.md # 必需,给 Agent +├── README.md # 必需,给人类 ├── reference.md # 可选 ├── examples.md # 可选 └── scripts/ # 可选 @@ -285,7 +275,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` 验证 @@ -341,8 +331,8 @@ Claude Code 对 symlink 支持不稳定:可能无法发现 skill,或写入 | ---------- | --------------------------------- | ---- | | 首次安装 | `git clone ~/.pouch && ~/.pouch/install.sh` | 任意 | | 新建 skill | `pouch create` → Agent 完善 → `check/finalize` | 任意项目 | -| 全局启用 | `pouch install ` | 任意 | -| 项目启用 | `pouch enable ` | 项目目录 | +| 全局启用 | `pouch add -g` | 任意 | +| 项目启用 | `pouch add ` 或 `pouch enable ` | 项目目录 | | 看状态 | `pouch status` | 任意 | | 装社区 skill | `npx skills add owner/repo -g -y` | 任意 | | 更新外部 skill | `pouch fetch ` | 任意 | diff --git a/README.md b/README.md index 521a7f4..505cec9 100644 --- a/README.md +++ b/README.md @@ -2,15 +2,26 @@ 自研 [Agent Skills](https://agentskills.io) 的单一事实来源(SSOT)。仓库、家目录 `~/.pouch` 和 CLI 都叫 **pouch**。Skill 内容与 CLI 在本仓库一并维护。 +人类用法看各 skill 的 `README.md`;`SKILL.md` 是给 Agent 的指令。用户要你代装时,按 [让 Agent 代装](#让-agent-代装) 自己执行,不要只把命令贴回去。 + +## 前置 + +- 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,10 +35,77 @@ mv ~/.skills ~/.pouch `install.sh` 会安装 `pouch` 命令,并保留 `pouch` 作为旧命令别名。项目里已有的 `.skills.yaml` 仍可读取;新写入使用 `.pouch.yaml`。 +## 让 Agent 代装 + +用户说「帮我安装 pouch」「把 skill 装上」时按本节做。CLI 只认 `~/.pouch`。不要用 `pouch select`(必须交互式 TTY)。不要在未确认时覆盖已有的 `~/.pouch`。 + +### 1. 确认本机 + +需要 `python3` 和 `git`。缺了先说明怎么装,不要继续。 + +### 2. 让 `~/.pouch` 指向本仓库 + +按现状只走一条: + +| 现状 | 动作 | +| --- | --- | +| 当前目录就是这份仓库(有 `install.sh` 和 `skills/`),且没有 `~/.pouch` | `ln -sfn "$(pwd)" "$HOME/.pouch"` | +| `~/.pouch` 已在且含 `skills/`、`install.sh` | 沿用 | +| 只有 `~/.skills` | `mv ~/.skills ~/.pouch` | +| 都没有 | `git clone https://git.yumee.top/laily/pouch.git ~/.pouch`。失败则问用户本地路径或可用 remote | +| `~/.pouch` 在,但不是这份仓库 | 停下问用户,不要删、不要覆盖 | + +### 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`。不要让用户重开终端才继续。 + +### 4. 安装用户要的其它 skill + +先 `pouch add --list`。用户没点名就问一次:装哪些、全局还是当前项目。能判断就不要追问。 + +```bash +pouch add -g -y # 全局,所有项目都能用 +pouch add -y # 仅当前项目 +``` + +默认 `pouch add` 装到当前工作目录。cwd 是 pouch 仓库本身时用 `-g`,除非用户就在维护 pouch。用户是在自己的应用仓库里请你代装,才做项目级安装。 + +`install.sh` 已经全局装好 `pouch` skill。其它 skill 不要默认全装。 + +### 5. 验收并告诉用户下一步 + +```bash +pouch status +pouch doctor +``` + +说明:打开 Cursor / Claude Code / Codex 后,按各 skill README 里的示例说话即可。软链异常则 `pouch doctor --fix`。已经装好时只汇报状态,问还要不要加 skill,不要重新 clone。 + +## 装完之后怎么用 + +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 来源目录 @@ -36,7 +114,7 @@ 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) | 设计原则、编写规范、架构详解 | @@ -44,14 +122,19 @@ 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](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 低保真原型和用户动线 | +| [manage-release](skills/manage-release/README.md) | 给已准备好的 commit 发版本:更新说明、tag、可选 Forge Release | +| [memory-distill](skills/memory-distill/README.md) | 任务结束后把可复用结论写入通用记忆和项目记忆 | +| [orc](skills/orc/README.md) | 把开发、发版、DEB / Docker 意图编排成阶段,按档位分发给对应 skill | +| [pouch](skills/pouch/README.md) | 创建、校验、安装和维护 builtin skill | -ACK 是包含规范、模板与校验脚本的完整 Skill。安装 Skill 后可初始化当前项目状态: +ACK 是包含规范、模板与校验脚本的完整 Skill。安装后可初始化当前项目状态: ```bash pouch init ack @@ -62,11 +145,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 +174,7 @@ pouch add -g -a cursor ```yaml skills: - - name: declarative-openspec-loop + - name: ack source: builtin - name: think source: catalog:waza @@ -101,9 +186,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 +218,7 @@ npx skills find typescript ``` 本仓库 -├── skills// ←── pouch install / enable +├── skills// ←── pouch add / select ├── catalog.yaml ←── 预置来源发现与 fetch └── pouch/ ←── python3 -m pouch ↑ @@ -159,6 +244,7 @@ npx skills find typescript - [AGENTS.md](AGENTS.md) — 完整规范、多 Agent 路径、编写约定 - [pouch/README.md](pouch/README.md) — CLI 命令参考与开发说明 +- 各 skill 的人类说明见上表 `README.md` ## 参考 diff --git a/openspec/config.yaml b/openspec/config.yaml deleted file mode 100644 index 392946c..0000000 --- a/openspec/config.yaml +++ /dev/null @@ -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 diff --git a/pouch/README.md b/pouch/README.md index f7b3cce..fa248c4 100644 --- a/pouch/README.md +++ b/pouch/README.md @@ -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//README.md` diff --git a/skills/ack/README.md b/skills/ack/README.md index 8a4e139..9435b4f 100644 --- a/skills/ack/README.md +++ b/skills/ack/README.md @@ -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/` | | 运行版本发布 | `/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 /scripts/validate_tasks.py .pouch/ack/tasks.yaml -python3 /scripts/validate_knowledge.py .pouch/ack/knowledge.yaml \ - --tasks .pouch/ack/tasks.yaml -python3 /scripts/validate_delivery.py .pouch/ack/delivery.yaml \ - --tasks .pouch/ack/tasks.yaml --project-root -python3 /scripts/validate_regression.py .pouch/ack/regression.yaml \ - --tasks .pouch/ack/tasks.yaml -``` - -Coordinator 可以按当前任务上下文做确定性推荐: - -```bash -python3 /scripts/select_tasks.py .pouch/ack/tasks.yaml -python3 /scripts/select_tasks.py .pouch/ack/tasks.yaml \ - --task-id BUG-001 - -python3 /scripts/select_knowledge.py .pouch/ack/knowledge.yaml \ - --component web --path web/app.py --tag long-running-service --limit 10 - -python3 /scripts/select_regression.py .pouch/ack/regression.yaml -python3 /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 /scripts/run_verification.py \ - .pouch/ack/knowledge.yaml check-api-contract --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/` 还没就绪时,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 /scripts/launch_worker.py plan \ - --project-root \ - --task-id \ - --attempt-id -A \ - --role developer \ - --profile-id codex-dev-standard \ - --worktree -``` - -确认后执行 `launch`,并显式绑定刚审阅的 fingerprint: - -```bash -python3 /scripts/launch_worker.py launch \ - --project-root \ - --task-id \ - --attempt-id -A \ - --role developer \ - --profile-id codex-dev-standard \ - --worktree \ - --expected-launch-fingerprint -``` - -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: }`。旧项目可以不迁移回归目录而继续使用 -原闭环。旧项目的 `kitVersion` 可以继续读取,但建议迁移为 `ackVersion`。 +当前 Skill 版本见 `VERSION`。新项目在 `tasks.yaml` 里记 `ackVersion`。 + +从 0.19.0 起,测试环境写成 `{via: deployer, env: }`,回归目录是 `.pouch/ack/regression.yaml`。旧的测试环境 profile ID 字符串不再执行,需要迁过去。旧项目可以不迁回归目录,继续用原来的闭环。旧的 `kitVersion` 仍可读,建议改成 `ackVersion`。 diff --git a/skills/builder/README.md b/skills/builder/README.md index dc6241e..9ef85ef 100644 --- a/skills/builder/README.md +++ b/skills/builder/README.md @@ -14,6 +14,23 @@ 只构建不上传时明确说明即可;上传永远需要你显式授权。 +## 安装 + +```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 会探测轨道、按 @@ -98,6 +115,22 @@ detached HEAD 用 `detached`,CI 可注入 `BUILD_BRANCH` / `CI_COMMIT_BRANCH` 用 builder 发布多平台 linux/amd64,linux/arm64 镜像。 ``` +## Agent 会做什么 + +1. 初始化:探测 DEB / Docker 轨道,写出 `makefile.builder` 和 `.env.builder` 骨架,不改你已有的 `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` 盖住正式包。 + ## 脚本一览 | 脚本 | 用途 | diff --git a/skills/declarative-openspec-loop/README.md b/skills/declarative-openspec-loop/README.md new file mode 100644 index 0000000..2b5d4ff --- /dev/null +++ b/skills/declarative-openspec-loop/README.md @@ -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//expectation.md`。 +2. 必要时先探索;然后 propose 出 proposal / design / tasks,每个任务尽量带验收。 +3. apply 实现,再跑你给的校验。 +4. 失败按原因分级:小 bug 直接修;任务遗漏就补 tasks;方向错了才改设计。默认最多 5 轮(你可改上限)。同一错误连续 2 轮会停下来问你。 +5. 通过后给出变更摘要和思路,过程记录留在 `docs/tmp//`。 + +不会在没校验方式时开跑,也不会到上限后自己放弃或无限重试。 + +## 如何判断完成 + +- 你指定的校验命令退出码 0,或校验项全部满足。 +- Agent 给出变更文件列表和思路。 +- `docs/tmp//` 里有 `expectation.md`、每轮 `iteration_*.md` 和校验原始输出。 +- 到上限或反复失败时停下,并说明当前进度和未通过项,而不是假装完成。 diff --git a/skills/deployer/README.md b/skills/deployer/README.md index 270b904..8dd11cb 100644 --- a/skills/deployer/README.md +++ b/skills/deployer/README.md @@ -22,6 +22,15 @@ - 需要一张「哪台机器跑哪些服务」的清单 - 镜像要进 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) @@ -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,自动排除 `data/`、`_data/`),再远程 `docker compose`(启动 / 重建 / 升级 / 重启) 4. deb 包安装走独立脚本:scp 上传到暂存目录后远程 apt 安装,失败自动修依赖 5. 同步后查看容器状态和日志确认生效 6. 只针对你指定的那一个服务操作,不会批量动整台机器 @@ -78,8 +88,8 @@ GitOps 我已经 clone 在 ../infra-gitops,用那个目录开 MR ## 如何判断完成 -- 脚本输出显示同步完成、远程命令执行成功 -- `ps` 显示容器 Up、`logs` 无报错;升级后镜像 tag 与配置一致 +- 本机:`docker compose ps` 容器 Up。远程:sync 成功且 `remote.py ps` 容器 Up +- `logs` 无报错;升级后镜像 tag 与配置一致 - deb 安装后 `ssh dpkg -l` 能看到目标包,服务能正常启动 - 域名/端口类服务能 curl 通 - Argo CD:给出 MR 链接;合入后 Application Synced,域名/healthz 可访问 diff --git a/skills/deployer/SKILL.md b/skills/deployer/SKILL.md index d45f749..366441b 100644 --- a/skills/deployer/SKILL.md +++ b/skills/deployer/SKILL.md @@ -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。 ## 边界 @@ -66,11 +66,22 @@ python3 -I -S /scripts/deploy/check.py --project ## 被 ACK 调用 -ACK 的「运行测试环境」和回归前布环境会加载本 skill,对 `.pouch/deployer/`(通常是 `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 ps` / `logs` 验证。 +重启 / 重新部署 / 同步 / 布测试环境走默认路径。不要读 compose.md、不要初始化、不要读 Makefile。 + +1. 在项目根执行:`python3 -I -S /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//` 执行 `docker compose up -d --force-recreate`,再 `ps`。完。 +5. 否则才 sync + remote:`python3 -I -S /scripts/deploy/sync.py ` 然后 `python3 -I -S /scripts/deploy/remote.py recreate`。只要重启:`remote.py restart`。 +6. 远程路径验证:`remote.py ps`;异常再 `logs`。完。 + +compose 用 `image:` 且用户说刚合并了代码:现有 tag 对不上这次提交时,**一句问**要不要先重建镜像,然后停。不要自己改 tag、不要走 builder。 + +新增/下线、deb、镜像 tag 升级、NAS 失败:再读 [compose.md](references/compose.md)。字段见 [config-reference.md](references/config-reference.md)。 ## Argo CD diff --git a/skills/deployer/references/compose.md b/skills/deployer/references/compose.md index d27d48c..cdeae69 100644 --- a/skills/deployer/references/compose.md +++ b/skills/deployer/references/compose.md @@ -81,6 +81,8 @@ python3 /scripts/deploy/sync.py | 意图 | 命令 | |------|------| +| 本机已有镜像(node 是 localhost,image 已在本机 docker) | 不要 sync。在 env 目录 `docker compose up -d --force-recreate` | +| 重新部署远程环境 | `sync.py && remote.py recreate` | | 只同步文件,不动容器 | `sync.py ` | | 应用 compose/配置变更 | `sync.py && remote.py up` | | 改配置后强制重建 | `remote.py recreate`(配合前置 sync) | @@ -88,7 +90,7 @@ python3 /scripts/deploy/sync.py | 仅重启,不同步文件 | `remote.py restart` | | 排查 | `remote.py ps` / `remote.py logs` | -若项目有 Makefile 封装,命令形如 `make deploy TGT=<服务路径>`。没有 Makefile 时直接调 python 脚本,不要新建封装层。 +脚本一律 `python3 -I -S /scripts/deploy/.py`。有 `make deploy TGT=` 才用 Makefile;没有就直接调脚本,不要读 Makefile 去发明封装。 `sync.py` 使用 `rsync -avz --delete`;本机无 rsync 时 tar over SSH 兜底。运行时数据必须放在默认排除的 `data/`、`_data/`,或 compose 挂载的远程绝对路径,否则会被清掉。 diff --git a/skills/deployer/scripts/deploy/deb.py b/skills/deployer/scripts/deploy/deb.py index 7e0e77e..cde6bee 100755 --- a/skills/deployer/scripts/deploy/deb.py +++ b/skills/deployer/scripts/deploy/deb.py @@ -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, diff --git a/skills/deployer/scripts/deploy/list.py b/skills/deployer/scripts/deploy/list.py index 8f4ed40..61aacea 100755 --- a/skills/deployer/scripts/deploy/list.py +++ b/skills/deployer/scripts/deploy/list.py @@ -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: diff --git a/skills/deployer/scripts/deploy/remote.py b/skills/deployer/scripts/deploy/remote.py index 2c95302..2532fe5 100755 --- a/skills/deployer/scripts/deploy/remote.py +++ b/skills/deployer/scripts/deploy/remote.py @@ -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", diff --git a/skills/deployer/scripts/deploy/sync.py b/skills/deployer/scripts/deploy/sync.py index 7e4577d..9fa5fc2 100755 --- a/skills/deployer/scripts/deploy/sync.py +++ b/skills/deployer/scripts/deploy/sync.py @@ -16,8 +16,13 @@ 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, project_root, rsync_ssh_args, service_info, ssh_cmd # noqa: E402 def sync_directory(info: dict) -> int: diff --git a/skills/discussion-notes/README.md b/skills/discussion-notes/README.md new file mode 100644 index 0000000..46b4f10 --- /dev/null +++ b/skills/discussion-notes/README.md @@ -0,0 +1,57 @@ +# discussion-notes + +边讨论边把可复用结论写进项目里的 Markdown 笔记。笔记是活的:理解变了就改,不另存聊天稿。 + +## 什么时候使用 + +- 要进入讨论模式、沉淀到文档、整理笔记、单开文档。 +- 适合学习概念、做技术选型、把讨论变成可回顾的结论。 +- 不适合:一次性问答、必须进 skill 记忆库的沉淀(用 `memory-distill`)、也不要用来当会议逐字稿。 + +说「结束讨论」或「不用记了」就退出;之后不再自动改笔记。 + +## 安装 + +```bash +pouch add discussion-notes # 当前项目 +pouch add discussion-notes -g # 全局 +``` + +## 使用前准备 + +- 指定笔记路径,或接受默认 `000inbox/.md`(按 Obsidian 收件箱习惯)。 +- 有明确话题名更好;没有的话 Agent 会问一次。 +- 笔记写在当前项目仓库内。默认不 git commit。 + +## 使用示例 + +```text +进入讨论模式,记到 000inbox/podman-vs-docker.md:公司 Debian VM 上容器运行时选 Podman 还是 Docker。 +``` + +```text +讨论 Quadlet 是什么,单开一篇概念笔记,并链到刚才的选型文档。 +``` + +```text +这轮不用记,先只回答。 +``` + +```text +结束讨论。 +``` + +## Agent 会做什么 + +1. 确认路径:你指定的文件,或默认 `000inbox/.md`。新话题新建,续聊改已有文件。 +2. 按内容选结构:选型/决策,或概念/原理。模板见 [reference.md](reference.md)。 +3. 每轮先用中文回答你,再更新笔记里的结论、对比、约束;纠正旧理解时直接改,不留错误版本。 +4. 只写可复用知识,不把问答全文抄进文档,不创建 `.raw.md`。 +5. 你说结束时,可选加一节「更新」:搞懂了、纠正了、待动手。 + +## 如何判断完成 + +- 约定路径上有一份正在维护的 `.md`,结构能看出结论而不是聊天记录。 +- 本轮新结论已经写进对应章节;过时说法被改掉,而不是追加在文末。 +- 没有出现 `xxx.raw.md`,也没有未经确认的 git commit。 +- 退出讨论后,Agent 不再自动改那份笔记。 diff --git a/skills/draw-prototype-flow/README.md b/skills/draw-prototype-flow/README.md index 44aece7..466f61a 100644 --- a/skills/draw-prototype-flow/README.md +++ b/skills/draw-prototype-flow/README.md @@ -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。 diff --git a/skills/manage-release/README.md b/skills/manage-release/README.md index caf16cb..00a519d 100644 --- a/skills/manage-release/README.md +++ b/skills/manage-release/README.md @@ -4,6 +4,13 @@ 版本文件、worktree、分支、PR/MR 和合并。普通发布优先走“更新说明 + annotated tag”的 短路径;只有发布要求修改仓库内容时,才进入准备版本的分支流程。 +## 安装 + +```bash +pouch add manage-release # 当前项目 +pouch add manage-release -g # 全局 +``` + ## 使用前准备 - 项目已经是 Git 仓库,并配置了可访问的远端。 diff --git a/skills/memory-distill/README.md b/skills/memory-distill/README.md index aff63e8..123711e 100644 --- a/skills/memory-distill/README.md +++ b/skills/memory-distill/README.md @@ -12,6 +12,13 @@ memory-distill 提炼一下刚刚 ack 执行过程中可以沉淀的知识。 也会响应「沉淀记忆」「回顾刚才记下来」「更新记忆」等说法。 +## 安装 + +```bash +pouch add memory-distill # 当前项目 +pouch add memory-distill -g # 全局 +``` + ## 使用前准备 通常不用准备路径。Agent 会: diff --git a/skills/orc/README.md b/skills/orc/README.md index 416598f..4311b92 100644 --- a/skills/orc/README.md +++ b/skills/orc/README.md @@ -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。 diff --git a/skills/pouch/README.md b/skills/pouch/README.md index c936d67..2ef59c7 100644 --- a/skills/pouch/README.md +++ b/skills/pouch/README.md @@ -1,7 +1,6 @@ # pouch -`pouch` 用于创建、维护、安装和发布团队自研的 Agent Skill。Skill 的唯一来源位于 -`~/.pouch/skills//`,安装到各 Agent 时使用软链接。 +创建、维护、安装和发布团队自研的 Agent Skill。唯一来源在 `~/.pouch/skills//`,装到各 Agent 时用软链。 ## 什么时候使用 @@ -11,6 +10,25 @@ - 想检查并修复 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 +帮我把「评审 PR 时按这份清单走」做成一个自研 skill。 +pouch 帮我检查 discussion-notes 这个 skill。 +pouch 帮忙优化 ack 这个 skill 结构,减少 token 浪费。 +把 ack 装到当前项目的 Cursor。 +检查一下 skill 软链,坏了就修。 +``` + ## 创建一个 skill ```bash @@ -32,6 +50,8 @@ pouch check my-skill pouch finalize my-skill ``` +转正不会自动 commit、push 或安装。 + ## 优化已有 skill 的结构 skill 能用但正文太长、所有模式写在一份 `SKILL.md` 里,或点中后把用不到的参考一并读进上下文时: @@ -40,42 +60,62 @@ 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/.drafts/`,按你的想法完善 `SKILL.md` 和给人看的 `README.md`。 +2. 跑 `pouch check`;你确认后再 `finalize` 到 `skills/`。 +3. 安装只建指向 SSOT 的软链,不复制文件。默认装当前项目;只有你明确要求时才加 `-g`。 +4. 优化结构时先测量再改 SSOT,不删发版、部署类安全限制。 +5. 未要求时不 commit、不 push、不全局安装。 + ## 如何判断完成 - `pouch check ` 输出校验通过。 - 正式 skill 同时包含 `SKILL.md` 和 `README.md`。 - `pouch status` 或 `pouch doctor` 显示目标软链接正常。 - 优化结构时:审计报告有前后体积,安全限制仍在 `SKILL.md`,没有为缩字删掉模式。 - diff --git a/tests/test_deployer_check.py b/tests/test_deployer_check.py index 58e5c4d..4600911 100644 --- a/tests/test_deployer_check.py +++ b/tests/test_deployer_check.py @@ -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 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) From b28acc66d86bef363a41516d8ca584f18572d9c6 Mon Sep 17 00:00:00 2001 From: laily Date: Fri, 4 Sep 2026 11:54:07 +0800 Subject: [PATCH 02/10] feat: add host-sec-check --- skills/host-sec-check/README.md | 41 ++++++ skills/host-sec-check/SKILL.md | 130 ++++++++++++++++++ skills/host-sec-check/references/access.md | 48 +++++++ skills/host-sec-check/references/checklist.md | 75 ++++++++++ skills/host-sec-check/references/cleanup.md | 59 ++++++++ .../host-sec-check/references/persistence.md | 40 ++++++ skills/host-sec-check/scripts/collect.sh | 53 +++++++ skills/note-style/SKILL.md | 89 ++++++++++++ skills/note-style/agents/openai.yaml | 4 + 9 files changed, 539 insertions(+) create mode 100644 skills/host-sec-check/README.md create mode 100644 skills/host-sec-check/SKILL.md create mode 100644 skills/host-sec-check/references/access.md create mode 100644 skills/host-sec-check/references/checklist.md create mode 100644 skills/host-sec-check/references/cleanup.md create mode 100644 skills/host-sec-check/references/persistence.md create mode 100755 skills/host-sec-check/scripts/collect.sh create mode 100644 skills/note-style/SKILL.md create mode 100644 skills/note-style/agents/openai.yaml diff --git a/skills/host-sec-check/README.md b/skills/host-sec-check/README.md new file mode 100644 index 0000000..74dd52a --- /dev/null +++ b/skills/host-sec-check/README.md @@ -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 不应出现在完整原文里。 diff --git a/skills/host-sec-check/SKILL.md b/skills/host-sec-check/SKILL.md new file mode 100644 index 0000000..220698b --- /dev/null +++ b/skills/host-sec-check/SKILL.md @@ -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 /scripts/collect.sh USER@HOST +``` + +`` 是本 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` 仍在 diff --git a/skills/host-sec-check/references/access.md b/skills/host-sec-check/references/access.md new file mode 100644 index 0000000..bc0a6de --- /dev/null +++ b/skills/host-sec-check/references/access.md @@ -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` 里出现的随机名容器就是这个跳板。 diff --git a/skills/host-sec-check/references/checklist.md b/skills/host-sec-check/references/checklist.md new file mode 100644 index 0000000..433cd75 --- /dev/null +++ b/skills/host-sec-check/references/checklist.md @@ -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. 报告与处置确认 + +- [ ] 结论表 +- [ ] 用户确认后再改 +- [ ] 改完公网+域名双侧验证 diff --git a/skills/host-sec-check/references/cleanup.md b/skills/host-sec-check/references/cleanup.md new file mode 100644 index 0000000..4fddd1c --- /dev/null +++ b/skills/host-sec-check/references/cleanup.md @@ -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 /proc/*/maps` 为 0;若已清 hugepage 则 `HugePages_Total` 为 0;域名 200。 diff --git a/skills/host-sec-check/references/persistence.md b/skills/host-sec-check/references/persistence.md new file mode 100644 index 0000000..3e94a90 --- /dev/null +++ b/skills/host-sec-check/references/persistence.md @@ -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 diff --git a/skills/host-sec-check/scripts/collect.sh b/skills/host-sec-check/scripts/collect.sh new file mode 100755 index 0000000..4a5aa96 --- /dev/null +++ b/skills/host-sec-check/scripts/collect.sh @@ -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 解读;不要把本输出里的密钥贴进聊天。" diff --git a/skills/note-style/SKILL.md b/skills/note-style/SKILL.md new file mode 100644 index 0000000..b981c5a --- /dev/null +++ b/skills/note-style/SKILL.md @@ -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. 不解释改写过程,不输出修改心得,不复述本技能的要求。 diff --git a/skills/note-style/agents/openai.yaml b/skills/note-style/agents/openai.yaml new file mode 100644 index 0000000..d9caa6b --- /dev/null +++ b/skills/note-style/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "教科书式笔记整理" + short_description: "将材料压缩并改写为准确、简洁的结构化知识笔记" + default_prompt: "使用 $note-style 将这份材料整理为可长期查阅的知识笔记。" From 17643bc17b39ab13d2ae0a9fd40885af623de21e Mon Sep 17 00:00:00 2001 From: laily Date: Fri, 4 Sep 2026 23:15:03 +0800 Subject: [PATCH 03/10] feat: add eli5 Port anthropics/claude-plugins-community eli5 into builtin skills as a single-page HTML picture explainer for readers with no background. --- AGENTS.md | 1 + README.md | 1 + skills/eli5/README.md | 53 +++++++++++++++++++++++++++++++++++++++++++ skills/eli5/SKILL.md | 52 ++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 107 insertions(+) create mode 100644 skills/eli5/README.md create mode 100644 skills/eli5/SKILL.md diff --git a/AGENTS.md b/AGENTS.md index 0d0d10d..e1556ac 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -54,6 +54,7 @@ AGENTS.md # 本文档 | [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 把话题讲给完全没背景的人听 | | [manage-release](skills/manage-release/README.md) | 给已准备好的 commit 发版本:更新说明、tag、可选 Forge Release | | [memory-distill](skills/memory-distill/README.md) | 任务结束后把可复用结论写入通用记忆和项目记忆 | | [orc](skills/orc/README.md) | 把开发、发版、DEB / Docker 意图编排成阶段,按档位分发给对应 skill | diff --git a/README.md b/README.md index 505cec9..058b880 100644 --- a/README.md +++ b/README.md @@ -129,6 +129,7 @@ AGENTS.md # 详细规范与架构说明 | [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 把话题讲给完全没背景的人听 | | [manage-release](skills/manage-release/README.md) | 给已准备好的 commit 发版本:更新说明、tag、可选 Forge Release | | [memory-distill](skills/memory-distill/README.md) | 任务结束后把可复用结论写入通用记忆和项目记忆 | | [orc](skills/orc/README.md) | 把开发、发版、DEB / Docker 意图编排成阶段,按档位分发给对应 skill | diff --git a/skills/eli5/README.md b/skills/eli5/README.md new file mode 100644 index 0000000..93199ca --- /dev/null +++ b/skills/eli5/README.md @@ -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,不依赖外网资源。 +- 画面上图大于字;没背景的人能看出「这东西怎么走」。 +- 聊天没有再贴一遍长文解释。 diff --git a/skills/eli5/SKILL.md b/skills/eli5/SKILL.md new file mode 100644 index 0000000..5fa135d --- /dev/null +++ b/skills/eli5/SKILL.md @@ -0,0 +1,52 @@ +--- +name: eli5 +description: >- + 用大图少字的单页 HTML 把话题讲给完全没背景的人听。Use when the user types + /eli5 , 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 `、`eli5`、像五岁小孩一样解释、用小学生能懂的方式讲、画图解释 +- 用户要一份一眼能看懂的图解,而不是长文 + +## 不适用 + +- 精确规格、边界条件、安全审计、修代码 +- 高保真 UI 或可编辑原型 → `draw-prototype-flow` +- 边讨论边写笔记 → `discussion-notes` + +--- + +## 步骤 + +1. **取话题**。用户没给就问一次。 +2. **读者设定**:零背景,不是幼儿口吻。去掉默认假设,不要装小孩说话。 +3. **写一份自包含 HTML**(无外部 CSS / JS / 字体 / 图片 URL): + - 一页看完 + - 结构用大图讲(inline SVG;可少量 emoji) + - 文字极短:标题、箭头旁短语、一句总收束 + - 至少把核心机制拆成 3 个可见步骤或块 +4. **落盘**。用户指定路径就用;否则写到当前项目根(没有项目就用 cwd)`eli5-.html`。Claude Code 若支持 artifact,可同时打开,**仍要写文件**。 +5. **聊天里只给路径和一句总收束**,不要把正文再讲一遍。 + +## 注意事项 + +- 不为了好懂而编造机制。不确定就标「简化」,不要装精确。 +- 不 git commit。 +- 语言跟用户请求走;没点名时用简体中文。 + +## 验证 + +- 用浏览器或 `file://` 打开是单页,图明显大于字。 +- 没背景的人能从画面看出「这东西怎么走」。 +- 仓库或 cwd 里有那份 HTML;聊天没有墙一样的解释。 From 83deb886b54a1f04fba4c6c4cdf0f13eb0c50223 Mon Sep 17 00:00:00 2001 From: laily Date: Mon, 7 Sep 2026 15:43:38 +0800 Subject: [PATCH 04/10] feat(deployer): fall back to scp when rsync is missing --- skills/deployer/README.md | 9 +- skills/deployer/SKILL.md | 2 +- skills/deployer/references/compose.md | 2 +- skills/deployer/scripts/deploy/check.py | 2 +- skills/deployer/scripts/deploy/sync.py | 93 ++++++++++----- tests/test_deployer_sync.py | 149 ++++++++++++++++++++++++ 6 files changed, 223 insertions(+), 34 deletions(-) create mode 100644 tests/test_deployer_sync.py diff --git a/skills/deployer/README.md b/skills/deployer/README.md index 8dd11cb..2b6e966 100644 --- a/skills/deployer/README.md +++ b/skills/deployer/README.md @@ -33,7 +33,7 @@ pouch add deployer -g # 全局 ## 使用前准备 -- 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。 @@ -73,7 +73,7 @@ GitOps 我已经 clone 在 ../infra-gitops,用那个目录开 MR 1. 读服务/环境目录(及共享的父目录)的 `_config.yaml`,确定目标机器和远程路径; 项目内布局从 `.pouch/deployer/` 自动发现,无需额外配置 2. 目标是本机且镜像已在本机 docker:不 sync、不 SSH,在该环境目录直接 `docker compose` -3. 否则用 skill 自带脚本把本地目录同步到远程(rsync,自动排除 `data/`、`_data/`),再远程 `docker compose`(启动 / 重建 / 升级 / 重启) +3. 否则用 skill 自带脚本把本地目录同步到远程(优先 rsync,没有则 scp;自动排除 `data/`、`_data/`),再远程 `docker compose`(启动 / 重建 / 升级 / 重启) 4. deb 包安装走独立脚本:scp 上传到暂存目录后远程 apt 安装,失败自动修依赖 5. 同步后查看容器状态和日志确认生效 6. 只针对你指定的那一个服务操作,不会批量动整台机器 @@ -83,8 +83,9 @@ GitOps 我已经 clone 在 ../infra-gitops,用那个目录开 MR 项目内布局下,远程目录名自动带上项目前缀(如 `my-project-prod`), 避免同一台机器上多个项目的同名环境互相覆盖;需要固定名字时在 `_config.yaml` 写 `name:`。 -重要边界:同步使用 `--delete`,远程多余的文件会被删除;数据库、证书等运行时数据 -必须放在排除目录或远程绝对路径挂载。涉及删除数据卷、清理远程文件的操作会先向你确认。 +重要边界:rsync 同步使用 `--delete`,远程多余的文件会被删除;scp 兜底不会删。 +数据库、证书等运行时数据必须放在排除目录或远程绝对路径挂载。涉及删除数据卷、 +清理远程文件的操作会先向你确认。 ## 如何判断完成 diff --git a/skills/deployer/SKILL.md b/skills/deployer/SKILL.md index 366441b..bbe3354 100644 --- a/skills/deployer/SKILL.md +++ b/skills/deployer/SKILL.md @@ -56,7 +56,7 @@ python3 -I -S /scripts/deploy/check.py --project 已具备: … 待配置: 路径 + 字段 + 可粘贴示例 + 缺了会挡住哪步 -工具链: ssh / rsync(缺则怎么装,不擅自安装) +工具链: ssh / rsync(无 rsync 时 sync 用 scp 兜底,不擅自安装) 下一步: 一句话 ``` diff --git a/skills/deployer/references/compose.md b/skills/deployer/references/compose.md index cdeae69..e735f79 100644 --- a/skills/deployer/references/compose.md +++ b/skills/deployer/references/compose.md @@ -92,7 +92,7 @@ python3 /scripts/deploy/sync.py 脚本一律 `python3 -I -S /scripts/deploy/.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 挂载的远程绝对路径,否则会被清掉。 ## 新增服务 / 环境 diff --git a/skills/deployer/scripts/deploy/check.py b/skills/deployer/scripts/deploy/check.py index 81086c7..8756b07 100644 --- a/skills/deployer/scripts/deploy/check.py +++ b/skills/deployer/scripts/deploy/check.py @@ -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") diff --git a/skills/deployer/scripts/deploy/sync.py b/skills/deployer/scripts/deploy/sync.py index 9fa5fc2..9a307f5 100755 --- a/skills/deployer/scripts/deploy/sync.py +++ b/skills/deployer/scripts/deploy/sync.py @@ -22,14 +22,14 @@ _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, project_root, rsync_ssh_args, service_info, ssh_cmd # noqa: E402 +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: @@ -63,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 diff --git a/tests/test_deployer_sync.py b/tests/test_deployer_sync.py new file mode 100644 index 0000000..3fe5581 --- /dev/null +++ b/tests/test_deployer_sync.py @@ -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() From 852dee3a694d4a507869f21beefb6e6e95aca948 Mon Sep 17 00:00:00 2001 From: laily Date: Tue, 8 Sep 2026 10:59:17 +0800 Subject: [PATCH 05/10] feat(builder): append YYYYMMDD to test versions --- skills/builder/README.md | 17 ++++++----- skills/builder/references/contract.md | 13 +++++---- skills/builder/scripts/version.sh | 24 +++++++++++---- tests/test_builder_version.py | 42 +++++++++++++++++++++++---- 4 files changed, 74 insertions(+), 22 deletions(-) diff --git a/skills/builder/README.md b/skills/builder/README.md index 9ef85ef..276b5c7 100644 --- a/skills/builder/README.md +++ b/skills/builder/README.md @@ -84,20 +84,23 @@ Docker registry/ns/foo:1.4.2 ### 测试(其它任何 commit) -基线 = 祖先上最近的那颗稳定 tag(没有则为 `0.0.0`),再加上清洗后的 -分支名、相对距离、短 SHA。测试与正式进**同一个** apt / Docker 仓库, -所以 DEB 必须用 `~`,保证测试包不会 `apt upgrade` 盖住正式包。 +基线 = 祖先上最近的那颗稳定 tag(没有则为 `0.0.0`),再加上构建日 +`YYYYMMDD`、清洗后的分支名、相对距离、短 SHA。测试与正式进**同一个** +apt / Docker 仓库,所以 DEB 必须用 `~`,保证测试包不会 `apt upgrade` +盖住正式包。日期写在 `~` 后面,这样 `1.4.2~20260908…` 仍小于 `1.4.2`。 -分支 `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.2~20260908.feat-login-v2.7+gabc1234 +文件名 foo_1.4.2~20260908.feat-login-v2.7+gabc1234_amd64.deb +Docker registry/ns/foo:1.4.2-20260908.feat-login-v2.7.gabc1234 ``` Docker tag 由规范版本映射:`~` → `-`,`+g` → `.g`(Docker 不允许 `~`)。 安装测试包必须显式指定版本或完整 tag,不能靠无参 `apt upgrade`。 +构建日取本机当前日期;设了 `SOURCE_DATE_EPOCH` 则用该时刻的 UTC 日期。 ### 分支名清洗 diff --git a/skills/builder/references/contract.md b/skills/builder/references/contract.md index 1aaf74d..78c84c0 100644 --- a/skills/builder/references/contract.md +++ b/skills/builder/references/contract.md @@ -74,17 +74,20 @@ manage-release,不猜测下一个正式 SemVer。正式 tag 由 manage-release | 判定 | 规范版本 | DEB 文件 | Docker tag | | --- | --- | --- | --- | | HEAD exact-match 稳定 tag `v1.4.2` | `1.4.2` | `name_1.4.2_.deb` | `1.4.2` | -| 其它 commit;祖先最近稳定 tag `v1.4.2` | `1.4.2~.+g` | `name_1.4.2~.+g_.deb` | `1.4.2-..g` | -| 祖先中没有稳定 tag | `0.0.0~+g` | 同上替换规范版本 | 同上映射 | +| 其它 commit;祖先最近稳定 tag `v1.4.2` | `1.4.2~..+g` | `name_1.4.2~..+g_.deb` | `1.4.2-...g` | +| 祖先中没有稳定 tag | `0.0.0~.+g` | 同上替换规范版本 | 同上映射 | 测试与正式进入同一 apt / Docker 仓库。测试 DEB 必须用 `~`,使 -`1.4.2~…` < `1.4.2`,`apt upgrade` 不会装上测试包。Docker tag 不得含 -`~`、`/`、`:`,由规范版本把 `~` → `-`、`+g` → `.g`。 +`1.4.2~…` < `1.4.2`,`apt upgrade` 不会装上测试包。日期必须写在 `~` +之后,不能写成 `1.4.2.` / `1.4.2+`,否则测试包会大于正式 +包。Docker tag 不得含 `~`、`/`、`:`,由规范版本把 `~` → `-`、`+g` → `.g`。 +``:构建日 `YYYYMMDD`。已设 `SOURCE_DATE_EPOCH` 时用该时刻的 UTC +日期,否则用本机当前日期。正式 `X.Y.Z` 不加日期。 ``:`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)。 `` 为基线 tag 到 HEAD 的 commit 数;`` 为 7 位短哈希。不要把 `--dirty` 写入版本;脏树发布仍走既有门禁。显式 `VERSION=` / `IMAGE_TAG=` 可覆盖推导,但不得把非 exact-match 的 commit 标成正式 `X.Y.Z`。 diff --git a/skills/builder/scripts/version.sh b/skills/builder/scripts/version.sh index 735a191..dbbc4b9 100755 --- a/skills/builder/scripts/version.sh +++ b/skills/builder/scripts/version.sh @@ -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 → X.Y.Z~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.. only. Baseline is ancestry, not the highest version in the repository. This script does not fetch tags. @@ -100,6 +103,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 +148,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 +156,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' "${base#v}" "$ymd" "$branch" "$n" "$sha" } to_docker() { diff --git a/tests/test_builder_version.py b/tests/test_builder_version.py index 32bc8fc..023af3b 100644 --- a/tests/test_builder_version.py +++ b/tests/test_builder_version.py @@ -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( @@ -113,12 +118,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.2~{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.2-{BUILD_DATE}.feat-login-v2.2.g{feature_sha}", ) def test_ignores_prerelease_and_app_tags(self) -> None: @@ -136,7 +141,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.0~{BUILD_DATE}.main.1+g{sha}") def test_no_stable_tag(self) -> None: with tempfile.TemporaryDirectory() as temp: @@ -148,7 +153,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 +169,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.0~{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.0~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: From 06865939e14f6720643bfa7c36554b232d565b1c Mon Sep 17 00:00:00 2001 From: laily Date: Tue, 8 Sep 2026 14:30:49 +0800 Subject: [PATCH 06/10] fix(builder): bump test versions to next patch After a stable tag vX.Y.Z, untagged commits now produce X.Y.(Z+1)~date.branch.n+gSHA so test DEBs sort above the last official release and still below the next official X.Y.(Z+1). --- skills/builder/README.md | 23 +++++++++++-------- skills/builder/references/contract.md | 17 ++++++++------ skills/builder/references/registry.md | 2 +- skills/builder/scripts/version.sh | 13 +++++++++-- tests/test_builder_version.py | 33 +++++++++++++++++++++++---- 5 files changed, 63 insertions(+), 25 deletions(-) diff --git a/skills/builder/README.md b/skills/builder/README.md index 276b5c7..68e1841 100644 --- a/skills/builder/README.md +++ b/skills/builder/README.md @@ -84,23 +84,26 @@ Docker registry/ns/foo:1.4.2 ### 测试(其它任何 commit) -基线 = 祖先上最近的那颗稳定 tag(没有则为 `0.0.0`),再加上构建日 -`YYYYMMDD`、清洗后的分支名、相对距离、短 SHA。测试与正式进**同一个** -apt / Docker 仓库,所以 DEB 必须用 `~`,保证测试包不会 `apt upgrade` -盖住正式包。日期写在 `~` 后面,这样 `1.4.2~20260908…` 仍小于 `1.4.2`。 +基线 = 祖先上最近的那颗稳定 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`, 构建日 `20260908`: ```text -规范 / DEB 1.4.2~20260908.feat-login-v2.7+gabc1234 -文件名 foo_1.4.2~20260908.feat-login-v2.7+gabc1234_amd64.deb -Docker registry/ns/foo:1.4.2-20260908.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`。 -构建日取本机当前日期;设了 `SOURCE_DATE_EPOCH` 则用该时刻的 UTC 日期。 +无参 `apt upgrade` 会从上一正式版升到下一 patch 的测试包,但不会盖住同号 +正式包。生产若不能自动吃到测试包,应拆仓库/组件。构建日取本机当前日期; +设了 `SOURCE_DATE_EPOCH` 则用该时刻的 UTC 日期。 ### 分支名清洗 @@ -132,7 +135,7 @@ Docker 只有你点名镜像时才走。token 不会写进输出、提交或回 - 初始化:`check.py --ready` 的构建项 PASS。只缺发布键算部分完成,仍可构建、不能上传。 - 构建:产物路径、规范版本、架构与 `make -f makefile.builder version` 一致。 - 发布:HTTP 成功或远端 digest 对得上;回复里有包名或镜像引用、SHA-256 / digest、源 commit。 -- 测试包不会通过无参 `apt upgrade` 盖住正式包。 +- 测试包不会通过无参 `apt upgrade` 盖住同号正式包;会作为下一 patch 候选盖住上一正式包。 ## 脚本一览 diff --git a/skills/builder/references/contract.md b/skills/builder/references/contract.md index 78c84c0..8d99f48 100644 --- a/skills/builder/references/contract.md +++ b/skills/builder/references/contract.md @@ -64,8 +64,9 @@ make -f makefile.builder `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,13 +75,15 @@ manage-release,不猜测下一个正式 SemVer。正式 tag 由 manage-release | 判定 | 规范版本 | DEB 文件 | Docker tag | | --- | --- | --- | --- | | HEAD exact-match 稳定 tag `v1.4.2` | `1.4.2` | `name_1.4.2_.deb` | `1.4.2` | -| 其它 commit;祖先最近稳定 tag `v1.4.2` | `1.4.2~..+g` | `name_1.4.2~..+g_.deb` | `1.4.2-...g` | +| 其它 commit;祖先最近稳定 tag `v1.4.2` | `1.4.3~..+g` | `name_1.4.3~..+g_.deb` | `1.4.3-...g` | | 祖先中没有稳定 tag | `0.0.0~.+g` | 同上替换规范版本 | 同上映射 | -测试与正式进入同一 apt / Docker 仓库。测试 DEB 必须用 `~`,使 -`1.4.2~…` < `1.4.2`,`apt upgrade` 不会装上测试包。日期必须写在 `~` -之后,不能写成 `1.4.2.` / `1.4.2+`,否则测试包会大于正式 -包。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+` / `1.4.3-beta`(Debian 里 `-beta` 大于 +`1.4.3`)。日期必须写在 `~` 之后。Docker tag 不得含 `~`、`/`、`:`,由规范 +版本把 `~` → `-`、`+g` → `.g`。 ``:构建日 `YYYYMMDD`。已设 `SOURCE_DATE_EPOCH` 时用该时刻的 UTC 日期,否则用本机当前日期。正式 `X.Y.Z` 不加日期。 diff --git a/skills/builder/references/registry.md b/skills/builder/references/registry.md index 8c48701..fe36418 100644 --- a/skills/builder/references/registry.md +++ b/skills/builder/references/registry.md @@ -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-.feat-login.7.gabc1234`),不要退回 `git describe`,不要询问后另选一套。 - 正式产物 tag 与 Git tag `vX.Y.Z` 对应,但镜像 tag **不含** `v`;默认视为不可变。 - `latest`、`stable`、裸分支名等浮动 tag 只有在用户明确要求时才额外发布, diff --git a/skills/builder/scripts/version.sh b/skills/builder/scripts/version.sh index dbbc4b9..152ed6c 100755 --- a/skills/builder/scripts/version.sh +++ b/skills/builder/scripts/version.sh @@ -11,7 +11,7 @@ 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~YYYYMMDD.branch.n+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 @@ -62,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 @@ -163,7 +172,7 @@ derive_canonical() { return fi n=$(gitc rev-list --count "${base}..HEAD") - printf '%s~%s.%s.%s+g%s' "${base#v}" "$ymd" "$branch" "$n" "$sha" + printf '%s~%s.%s.%s+g%s' "$(bump_patch "$base")" "$ymd" "$branch" "$n" "$sha" } to_docker() { diff --git a/tests/test_builder_version.py b/tests/test_builder_version.py index 023af3b..4543635 100644 --- a/tests/test_builder_version.py +++ b/tests/test_builder_version.py @@ -91,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) @@ -118,12 +141,12 @@ class BuilderVersionTests(unittest.TestCase): self.assertEqual(result.returncode, 0, result.stderr) self.assertEqual( result.stdout.strip(), - f"1.4.2~{BUILD_DATE}.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-{BUILD_DATE}.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: @@ -141,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~{BUILD_DATE}.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: @@ -169,7 +192,7 @@ 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~{BUILD_DATE}.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: @@ -184,7 +207,7 @@ class BuilderVersionTests(unittest.TestCase): 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.0~20240901.main.1+g{sha}") + 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: From 9b3e16cb63613cd7de58a35959a925a89bc7e57a Mon Sep 17 00:00:00 2001 From: laily Date: Tue, 8 Sep 2026 15:25:58 +0800 Subject: [PATCH 07/10] feat: update readme --- AGENTS.md | 6 +- README.md | 103 +++++---------- skills/manage-release/README.md | 6 + skills/note-style/README.md | 40 ++++++ skills/pouch/README.md | 19 ++- skills/pouch/SKILL.md | 36 ++++-- skills/pouch/references/install.md | 65 ++++++++++ skills/pouch/references/onboard.md | 49 ++++++++ skills/pouch/scripts/catalog.py | 168 +++++++++++++++++++++++++ skills/pouch/scripts/suggest_skills.py | 144 +++++++++++++++++++++ tests/test_pouch_catalog.py | 123 ++++++++++++++++++ tests/test_project_skill.py | 3 + 12 files changed, 676 insertions(+), 86 deletions(-) create mode 100644 skills/note-style/README.md create mode 100644 skills/pouch/references/install.md create mode 100644 skills/pouch/references/onboard.md create mode 100644 skills/pouch/scripts/catalog.py create mode 100644 skills/pouch/scripts/suggest_skills.py create mode 100644 tests/test_pouch_catalog.py diff --git a/AGENTS.md b/AGENTS.md index e1556ac..8251bbd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,7 +12,7 @@ ## 快速开始 -需要 Python 3、git,以及 Cursor / Claude Code / Codex 或其它兼容 Agent Skills 的 Agent。人类用法见根 [README.md](README.md) 和各 skill 的 `README.md`。用户要代装时按 README 的 [让 Agent 代装](README.md#让-agent-代装) 执行,不要只用 `pouch select`。 +需要 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 git clone https://git.yumee.top/laily/pouch.git ~/.pouch @@ -55,8 +55,10 @@ AGENTS.md # 本文档 | [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 | @@ -331,6 +333,8 @@ Claude Code 对 symlink 支持不稳定:可能无法发现 skill,或写入 | 我要… | 命令 | 在哪 | | ---------- | --------------------------------- | ---- | | 首次安装 | `git clone ~/.pouch && ~/.pouch/install.sh` | 任意 | +| 让 Agent 代装 | 说「帮我安装 pouch」或丢仓库 URL | 任意 | +| 介绍 / 接入 skill | 说「有哪些 skill」或「把这个项目该用的 skill 接上」 | 项目目录 | | 新建 skill | `pouch create` → Agent 完善 → `check/finalize` | 任意项目 | | 全局启用 | `pouch add -g` | 任意 | | 项目启用 | `pouch add ` 或 `pouch enable ` | 项目目录 | diff --git a/README.md b/README.md index 058b880..842274c 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,9 @@ 自研 [Agent Skills](https://agentskills.io) 的单一事实来源(SSOT)。仓库、家目录 `~/.pouch` 和 CLI 都叫 **pouch**。Skill 内容与 CLI 在本仓库一并维护。 -人类用法看各 skill 的 `README.md`;`SKILL.md` 是给 Agent 的指令。用户要你代装时,按 [让 Agent 代装](#让-agent-代装) 自己执行,不要只把命令贴回去。 +pouch 本义是小袋子,这里取做锦囊妙计的锦囊:skill 就是里面的妙计。 + +人类用法看各 skill 的 `README.md`;`SKILL.md` 是给 Agent 的指令。用户要你代装时,读 [skills/pouch/references/install.md](skills/pouch/references/install.md) 并执行,不要只把命令贴回去,也不要用 `pouch select`。 ## 前置 @@ -37,56 +39,12 @@ mv ~/.skills ~/.pouch ## 让 Agent 代装 -用户说「帮我安装 pouch」「把 skill 装上」时按本节做。CLI 只认 `~/.pouch`。不要用 `pouch select`(必须交互式 TTY)。不要在未确认时覆盖已有的 `~/.pouch`。 +对 Agent 说「帮我安装 pouch」,或把仓库地址丢给它。步骤只维护在 +[skills/pouch/references/install.md](skills/pouch/references/install.md): +确认 python3/git → 处理 `~/.pouch` → `install.sh` → 用短清单介绍 skill → 问要装哪些。 +不要用 `pouch select`。不要在未确认时覆盖已有的 `~/.pouch`。 -### 1. 确认本机 - -需要 `python3` 和 `git`。缺了先说明怎么装,不要继续。 - -### 2. 让 `~/.pouch` 指向本仓库 - -按现状只走一条: - -| 现状 | 动作 | -| --- | --- | -| 当前目录就是这份仓库(有 `install.sh` 和 `skills/`),且没有 `~/.pouch` | `ln -sfn "$(pwd)" "$HOME/.pouch"` | -| `~/.pouch` 已在且含 `skills/`、`install.sh` | 沿用 | -| 只有 `~/.skills` | `mv ~/.skills ~/.pouch` | -| 都没有 | `git clone https://git.yumee.top/laily/pouch.git ~/.pouch`。失败则问用户本地路径或可用 remote | -| `~/.pouch` 在,但不是这份仓库 | 停下问用户,不要删、不要覆盖 | - -### 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`。不要让用户重开终端才继续。 - -### 4. 安装用户要的其它 skill - -先 `pouch add --list`。用户没点名就问一次:装哪些、全局还是当前项目。能判断就不要追问。 - -```bash -pouch add -g -y # 全局,所有项目都能用 -pouch add -y # 仅当前项目 -``` - -默认 `pouch add` 装到当前工作目录。cwd 是 pouch 仓库本身时用 `-g`,除非用户就在维护 pouch。用户是在自己的应用仓库里请你代装,才做项目级安装。 - -`install.sh` 已经全局装好 `pouch` skill。其它 skill 不要默认全装。 - -### 5. 验收并告诉用户下一步 - -```bash -pouch status -pouch doctor -``` - -说明:打开 Cursor / Claude Code / Codex 后,按各 skill README 里的示例说话即可。软链异常则 `pouch doctor --fix`。已经装好时只汇报状态,问还要不要加 skill,不要重新 clone。 +自己装仍看上面的「快速开始」。 ## 装完之后怎么用 @@ -112,28 +70,34 @@ catalog.yaml # pouch 预置 Skill 来源目录 AGENTS.md # 详细规范与架构说明 ``` -| 路径 | 说明 | -|------|------| -| [skills/](skills/) | 自研 skill,每个子目录含给人看的 `README.md` 和给 Agent 的 `SKILL.md` | -| [pouch/](pouch/README.md) | 安装、软链、健康检查 CLI | -| [catalog.yaml](catalog.yaml) | pouch 预置 Skill 来源目录 | -| [AGENTS.md](AGENTS.md) | 设计原则、编写规范、架构详解 | + +| 路径 | 说明 | +| ---------------------------- | ----------------------------------------------------- | +| [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 | 说明 | -| --- | --- | -| [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 把话题讲给完全没背景的人听 | -| [manage-release](skills/manage-release/README.md) | 给已准备好的 commit 发版本:更新说明、tag、可选 Forge Release | -| [memory-distill](skills/memory-distill/README.md) | 任务结束后把可复用结论写入通用记忆和项目记忆 | -| [orc](skills/orc/README.md) | 把开发、发版、DEB / Docker 意图编排成阶段,按档位分发给对应 skill | -| [pouch](skills/pouch/README.md) | 创建、校验、安装和维护 builtin 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。安装后可初始化当前项目状态: @@ -253,3 +217,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) + diff --git a/skills/manage-release/README.md b/skills/manage-release/README.md index 00a519d..41ee313 100644 --- a/skills/manage-release/README.md +++ b/skills/manage-release/README.md @@ -20,6 +20,12 @@ pouch add manage-release -g # 全局 - 请求里写清目标版本、基线分支,以及允许执行到哪一步。没有明确授权的远端写操作 不会执行。 +## 使用示例 + +```text +使用 manage-release,为已经合并到 main 的 1.6.0 发布 v1.6.0 tag。不要创建 Forge Release。 +``` + ## 常见用法 ### 确定下一个版本号 diff --git a/skills/note-style/README.md b/skills/note-style/README.md new file mode 100644 index 0000000..52f5733 --- /dev/null +++ b/skills/note-style/README.md @@ -0,0 +1,40 @@ +# note-style + +把已有材料整理成可长期查阅的知识笔记:压缩空话、统一术语,保留事实、公式、条件和参考。 + +## 什么时候使用 + +- 要把聊天稿、草稿或 AI 生成的长文改成能放进知识库的笔记。 +- 要整理 Obsidian 笔记,去掉重复和口语。 +- 要把技术原理或操作方案写成教科书 / 手册口吻。 + +不适合:还在讨论、还没有可依据的材料,或需要 Agent 发明新结论。 + +## 使用前准备 + +- 给出要整理的材料(文件路径、粘贴正文,或当前对话里的那一段)。 +- 可选:目标读者、篇幅要求、笔记路径。不说则在原材料风格上压缩约三成。 + +## 使用示例 + +```text +用 note-style 把这篇对话整理成可放进 Obsidian 的技术笔记,保留公式和适用条件。 +``` + +```text +把 docs/draft-auth.md 改写成手册口吻,删掉空话,有疑点列待核实项。 +``` + +## Agent 会做什么 + +1. 抽出事实、定义、步骤、条件和参考,删掉铺垫和重复。 +2. 按材料类型选结构(原理或操作),用中性书面语重写全文。 +3. 先给你完整笔记;只有事实或技术疑点时才追加「待核实项」。 + +不会补没有依据的结论,也不会为缩短篇幅删掉必要条件。 + +## 如何判断完成 + +- 得到一份完整笔记,而不是改写说明。 +- 原文里的公式、条件、安全警告和参考还在。 +- 没有聊天式过渡或段末复述。 diff --git a/skills/pouch/README.md b/skills/pouch/README.md index 2ef59c7..ffc489a 100644 --- a/skills/pouch/README.md +++ b/skills/pouch/README.md @@ -7,6 +7,7 @@ - 想把项目里的重复工作沉淀成一个 skill。 - 想完善、校验、转正或发布已有 skill。 - 想把自研 skill 安装到当前项目或全局 Agent。 +- 想让 Agent 代装 pouch,或介绍有哪些 skill、在当前项目接入。 - 想检查并修复 skill 软链接。 - 想优化某个已有 skill 的结构,减少点中后灌进上下文的内容。 @@ -22,6 +23,10 @@ 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 浪费。 @@ -107,15 +112,19 @@ pouch doctor --fix ## Agent 会做什么 -1. 创建草稿到 `~/.pouch/.drafts/`,按你的想法完善 `SKILL.md` 和给人看的 `README.md`。 -2. 跑 `pouch check`;你确认后再 `finalize` 到 `skills/`。 -3. 安装只建指向 SSOT 的软链,不复制文件。默认装当前项目;只有你明确要求时才加 `-g`。 -4. 优化结构时先测量再改 SSOT,不删发版、部署类安全限制。 -5. 未要求时不 commit、不 push、不全局安装。 +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 ` 输出校验通过。 - 正式 skill 同时包含 `SKILL.md` 和 `README.md`。 - `pouch status` 或 `pouch doctor` 显示目标软链接正常。 +- 代装完成后 CLI 可用,且介绍了 builtin skill 的一句话和示例请求。 +- 项目接入后,建议的 skill 已 `pouch add`;ack / builder / deployer 要么已初始化,要么明确问过。 - 优化结构时:审计报告有前后体积,安全限制仍在 `SKILL.md`,没有为缩字删掉模式。 diff --git a/skills/pouch/SKILL.md b/skills/pouch/SKILL.md index 606c5aa..60f8c95 100644 --- a/skills/pouch/SKILL.md +++ b/skills/pouch/SKILL.md @@ -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//`。内容通过 **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 /scripts/catalog.py +``` + +把表格里的「做什么」和「可以这么说」原样告诉用户。用户点名再 `pouch add`。 + +## 在项目中使用 skill + +已点名则直接安装;没点名或要按仓库判断:读 [onboard.md](references/onboard.md)。 ```bash -pouch add --list pouch add -a cursor -a claude -a codex -a agents -y ``` -`pouch add ` 默认安装到当前项目;只有用户明确需要所有项目使用时才加 `-g`。安装结果是指向 `~/.pouch/skills//` 的软链,不要在 Agent 目录创建副本。 +默认当前项目;只有用户明确需要所有项目使用时才加 `-g`。只建指向 +`~/.pouch/skills//` 的软链,不要在 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 diff --git a/skills/pouch/references/install.md b/skills/pouch/references/install.md new file mode 100644 index 0000000..5e8fd80 --- /dev/null +++ b/skills/pouch/references/install.md @@ -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 ~/.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 -g -y # 全局,所有项目都能用 +pouch add -y # 仅当前项目 +``` + +默认 `pouch add` 装到当前工作目录。cwd 是 pouch 仓库本身时用 `-g`,除非用户就在维护 pouch。 +用户是在自己的应用仓库里请你代装,才做项目级安装。 + +## 5. 验收 + +```bash +pouch status +pouch doctor +``` + +软链异常则 `pouch doctor --fix`。已经装好时只汇报状态,问还要不要加 skill,不要重新 clone。 + +当前目录是业务仓库(不是 pouch 仓库)时,回到 SKILL.md,走「接入当前项目」。 diff --git a/skills/pouch/references/onboard.md b/skills/pouch/references/onboard.md new file mode 100644 index 0000000..5e99a42 --- /dev/null +++ b/skills/pouch/references/onboard.md @@ -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 /scripts/suggest_skills.py +``` + +只根据脚本输出建议,不要为了「可能有用」推荐 eli5、note-style、host-sec-check 这类无仓库信号的 skill。 +用户点名的除外。 + +## 2. 安装 + +把 `suggested` 和 `already` 都列为应安装的 skill(`already` 表示项目里已有状态, +软链仍要 `pouch add`,除非已经装上)。用户说「接上该用的」时只装这两类,不要全装 builtin。 +只对 `init` 列问要不要初始化。 + +默认当前项目: + +```bash +pouch add -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`,只念本次装上的那几行「可以这么说」。 diff --git a/skills/pouch/scripts/catalog.py b/skills/pouch/scripts/catalog.py new file mode 100644 index 0000000..19f828f --- /dev/null +++ b/skills/pouch/scripts/catalog.py @@ -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()) diff --git a/skills/pouch/scripts/suggest_skills.py b/skills/pouch/scripts/suggest_skills.py new file mode 100644 index 0000000..c3ab5ba --- /dev/null +++ b/skills/pouch/scripts/suggest_skills.py @@ -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()) diff --git a/tests/test_pouch_catalog.py b/tests/test_pouch_catalog.py new file mode 100644 index 0000000..18628cf --- /dev/null +++ b/tests/test_pouch_catalog.py @@ -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() diff --git a/tests/test_project_skill.py b/tests/test_project_skill.py index 1953177..000f6cc 100644 --- a/tests/test_project_skill.py +++ b/tests/test_project_skill.py @@ -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) From c248c3f0e3c246d1d86e9804daa8b947923e31c5 Mon Sep 17 00:00:00 2001 From: laily Date: Tue, 8 Sep 2026 15:40:22 +0800 Subject: [PATCH 08/10] feat: update readme --- README.md | 3 +- docs/getting-started.md | 234 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 236 insertions(+), 1 deletion(-) create mode 100644 docs/getting-started.md diff --git a/README.md b/README.md index 842274c..94e454a 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ pouch 本义是小袋子,这里取做锦囊妙计的锦囊:skill 就是里面的妙计。 -人类用法看各 skill 的 `README.md`;`SKILL.md` 是给 Agent 的指令。用户要你代装时,读 [skills/pouch/references/install.md](skills/pouch/references/install.md) 并执行,不要只把命令贴回去,也不要用 `pouch select`。 +给同事看怎么装、怎么打版本、怎么构建,读 [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`。 ## 前置 @@ -207,6 +207,7 @@ 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` diff --git a/docs/getting-started.md b/docs/getting-started.md new file mode 100644 index 0000000..486cf63 --- /dev/null +++ b/docs/getting-started.md @@ -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//`。装到 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= +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..`,不含 `-rc` 这类后缀。 + +HEAD 恰好是 `v1.6.0` 时,产物是正式的 `1.6.0`。其它 commit 打测试号:祖先最近稳定 tag 的 patch 机械 +1,再挂构建日、分支、距离、短 SHA。测试 DEB 用 `~`,于是 `1.6.0` < `1.6.1~…` < `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) + From aa7a265e76aeddfa3bf16ecf58670cd7783be39d Mon Sep 17 00:00:00 2001 From: laily Date: Tue, 8 Sep 2026 16:31:53 +0800 Subject: [PATCH 09/10] feat(builder): declare multi-arch builds with ARCHES Project architecture lists belong in makefile.builder, not .env.builder. deb/push-deb fan out when ARCH is omitted; Docker platforms derive from ARCHES. Legacy makefiles without ARCHES keep the old single-arch behavior. --- skills/builder/README.md | 51 ++++++- skills/builder/SKILL.md | 10 +- skills/builder/references/contract.md | 23 ++- skills/builder/references/registry.md | 2 +- skills/builder/scripts/check.py | 129 ++++++++++++++-- skills/builder/scripts/publish_docker.sh | 28 +++- skills/builder/templates/env.builder | 2 +- skills/builder/templates/makefile.builder | 51 ++++++- tests/test_builder_check.py | 173 ++++++++++++++++++++++ 9 files changed, 433 insertions(+), 36 deletions(-) diff --git a/skills/builder/README.md b/skills/builder/README.md index 68e1841..a74b2af 100644 --- a/skills/builder/README.md +++ b/skills/builder/README.md @@ -38,8 +38,9 @@ pouch add builder -g # 全局 不改用户已有的 `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 /scripts/version.mk` 从 Git 推导。 + 多架构清单是 `ARCHES`,见下方「多架构」。 2. 运行 `python3 -I -S /scripts/check.py . --ready` 直到构建项 PASS。 缺发布键只挡住上传,不挡住构建。 3. 在项目根 `.env.builder` 配置发布环境变量(不要写进 `.env`): @@ -54,6 +55,51 @@ DOCKER_REGISTRY=registry.example.com 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/__amd64.deb +dist/__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 /scripts/check.py . --ready`。 ## 版本号 @@ -119,11 +165,12 @@ detached HEAD 用 `detached`,CI 可注入 `BUILD_BRANCH` / `CI_COMMIT_BRANCH` 用 builder 构建当前版本的 DEB 和镜像,先不要上传。 用 builder 把当前 commit 的产物发布到项目已配置的仓库。 用 builder 发布多平台 linux/amd64,linux/arm64 镜像。 +用 builder 把当前版本的 amd64 和 arm64 DEB 打出来并发布。 ``` ## Agent 会做什么 -1. 初始化:探测 DEB / Docker 轨道,写出 `makefile.builder` 和 `.env.builder` 骨架,不改你已有的 `Makefile` 或 `.env`。 +1. 初始化:探测 DEB / Docker 轨道,写出 `makefile.builder` 和 `.env.builder` 骨架,按仓库证据填 `ARCHES`(不默认双架构),不改你已有的 `Makefile` 或 `.env`。 2. 检查:只跑 `scripts/check.py --ready`,不改文件。 3. 构建:走 `make -f makefile.builder`;只获构建授权时不走会自动上传的目标。 4. 发布:先校验产物,再上传。缺发布键、未授权、或可能覆盖同版本时停下问你。 diff --git a/skills/builder/SKILL.md b/skills/builder/SKILL.md index 2861961..767c18c 100644 --- a/skills/builder/SKILL.md +++ b/skills/builder/SKILL.md @@ -27,8 +27,8 @@ description: >- 1. 确认项目根。探测 `makefile.builder`、`.env.builder`、用户 Makefile(只当抄配方的证据,不改)、`Dockerfile`、`debian/`。`.env.builder` 只看键是否存在且非空,不读、不打印值。不要读取用户 `.env`。 2. 判定轨道:有 Dockerfile → docker;有 deb 信号或用户要打 deb → deb;都不清则问。不要猜测 registry、token 或仓库名。 -3. 没有 `makefile.builder`:把 `/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(版本规则冲突)。 +3. 没有 `makefile.builder`:把 `/templates/makefile.builder` 拷到项目根。按轨道删掉未使用的 deb/docker/push* 段,把 `build` 的 TODO 换成仓库里已有的真实编译命令(可从用户 Makefile 抄配方,但不要 `include` 或递归调用它),且配方必须使用 `$(ARCH)`。按 [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`。不要改用户 Makefile。 +4. 已有 `makefile.builder`:跑检查;按 FAIL 给出修补说明。不覆盖 `build`。用户明确要求按契约改时再加 `ARCHES`,并确认配方里有 `$(ARCH)`。不要调用 create-makefile(版本规则冲突)。 5. 没有 `.env.builder`:把 `/templates/env.builder` 拷到项目根(注释键,不含值)。缺发布键时在报告里给出可粘贴示例,并把 `.env.builder` 加入 `.gitignore`,不要提交。 6. 运行 `python3 -I -S /scripts/check.py --ready`。结构校验通过且当前轨道能构建才称「完成」;只缺发布键是「部分完成」。契约 FAIL 或轨道工具缺失是「阻塞」。除非用户明确要求,不提交、不推送、不上传。 @@ -59,7 +59,7 @@ DOCKER_REGISTRY=registry.example.com 1. 跑 `check.py `;需要工具链与发布键时加 `--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= VERSION=`;DEB 再 `make -f makefile.builder deb ARCH=<...>`。版本按契约从 Git 祖先稳定 tag 推导,不要调用 manage-release,不要用全仓库最新 tag。仅获构建授权时不要走会自动上传的目标。脚本含 `rm -rf` 时先确认为受限构建目录。 -4. DEB 上传前:`verify_deb.sh `。多个包时不凭文件时间猜测。镜像轨道由 `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 `。多个包时不凭文件时间猜测。镜像轨道由 `publish_docker.sh` 自带远端 inspect。 +5. 发布优先 `make -f makefile.builder push[-deb|-docker]`,或直接调 `upload_deb.sh` / `publish_docker.sh`。未指定 `ARCH=` 时 `push-deb` 上传清单里每一个 `.deb`。脚本缺环境变量时加载 `.env.builder`(shell 显式值优先),不读 `.env`。不把 token 当命令行参数;不把脚本复制进项目。多平台镜像只能走 `publish_docker.sh`,不能拆进 make;不要把项目架构清单写进 `.env.builder`。 6. 发布成功不能只看「curl/push 已执行」。要有退出码、HTTP 成功或远端 digest;索引异步时报告「上传已接受,索引尚待更新」。最终回复给出包名/镜像引用、版本、架构、SHA-256 或 digest、源 commit、工作区状态和未完成项。 diff --git a/skills/builder/references/contract.md b/skills/builder/references/contract.md index 8d99f48..a89c26a 100644 --- a/skills/builder/references/contract.md +++ b/skills/builder/references/contract.md @@ -28,15 +28,16 @@ make -f makefile.builder | `help` | 分组列出全部目标;首屏含当前版本 | | `version` | 输出一行版本号,适合脚本消费 | | `clean` | 只删除明确、受限的构建产物目录 | -| `build` | 编译/打包主产物;尊重 `ARCH`;**不得内含任何上传动作** | +| `build` | 编译/打包主产物;尊重 `ARCH`(单架构原语);**不得内含任何上传动作** | ### 条件目标 | 目标 | 适用 | 要求 | |------|------|------| -| `deb` | 有 DEB 产物的项目 | 产出唯一 `$(DIST_DIR)/__.deb`;只构建不上传 | +| `deb` | 有 DEB 产物的项目 | 产出 `$(DIST_DIR)/__.deb`;只构建不上传。未在命令行/环境指定 `ARCH=` 且 `ARCHES` 多于一项时,按清单逐个递归构建;指定了 `ARCH=` 则只打那一个 | | `docker` | 有镜像的项目 | 构建本地单平台镜像 `linux/$(ARCH)`;**禁止 `--push`、禁止多平台** | -| `push-deb` | 同时有 DEB 和镜像的项目 | 仅调 builder 的 `upload_deb.sh` 上传 `dist/*.deb` | +| `arches` | 可选(新模板必备) | 打印一行空格分隔的 `ARCHES`;无此目标视为存量 makefile,隐式单架构 | +| `push-deb` | 同时有 DEB 和镜像的项目 | 仅调 builder 的 `upload_deb.sh`。未指定 `ARCH=` 时上传 `ARCHES` 中每一项对应的 `.deb`;指定了则只上传那一个 | | `push-docker` | 同时有 DEB 和镜像的项目 | 仅调 builder 的 `publish_docker.sh` | | `push` | 单一产物类型时必备;双产物项目为聚合 | 依序调用对应 push-* 或直接调脚本;是发布的唯一 make 入口 | @@ -48,17 +49,27 @@ make -f makefile.builder `push` 即可。 3. `docker` 目标只能本地构建。多平台镜像无法拆成"make 构建 + 单独推送" (`buildx --push` 是一步),因此多平台发布只能走 `publish_docker.sh`。 + 未指定 `ARCH=` / `PLATFORMS` 时,`publish_docker.sh` 把 `ARCHES` 映射为 + `linux/,…`。 4. push 类目标必须是薄包装:解析脚本路径后委托,不内联 curl/token/端点。 +5. `build` 保持单架构,供 `deb` 循环调用。不要让 `build` 自己遍历 `ARCHES`。 ## 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 产物目录 | | `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 的 @@ -120,7 +131,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/,…`;再没有则 `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 已设置的值优先。 diff --git a/skills/builder/references/registry.md b/skills/builder/references/registry.md index fe36418..f5b627f 100644 --- a/skills/builder/references/registry.md +++ b/skills/builder/references/registry.md @@ -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/,…`,再否则 `linux/amd64` | | Dockerfile | 默认 `Dockerfile`,不存在或项目另有约定时明确指定 | | Context | 默认当前项目根目录 | diff --git a/skills/builder/scripts/check.py b/skills/builder/scripts/check.py index 8644cb0..6b8a411 100755 --- a/skills/builder/scripts/check.py +++ b/skills/builder/scripts/check.py @@ -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 ` 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: @@ -226,7 +315,9 @@ SCRIPT_RESOLVE_SNIPPETS = tuple( ) -def check_push_delegates(report: Report, project: Path, dual_artifact: bool) -> None: +def check_push_delegates( + report: Report, project: Path, dual_artifact: bool, deb_project: bool +) -> None: targets = ("push-deb", "push-docker") if dual_artifact else ("push",) missing = [] inline = [] @@ -250,6 +341,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 dual_artifact 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) @@ -453,7 +558,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), @@ -497,7 +602,7 @@ def main(argv: list[str] | None = None) -> int: 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)") @@ -517,7 +622,7 @@ def main(argv: list[str] | None = None) -> int: report.add(SKIP, 6, "docker 目标为本地单平台构建", "(no Dockerfile)") dual = deb_project and docker_project - check_push_delegates(report, project, dual) + check_push_delegates(report, project, dual, deb_project) check_secrets_and_tags(report, project) check_script_paths(report) if args.ready: diff --git a/skills/builder/scripts/publish_docker.sh b/skills/builder/scripts/publish_docker.sh index 018db63..e7b2b99 100755 --- a/skills/builder/scripts/publish_docker.sh +++ b/skills/builder/scripts/publish_docker.sh @@ -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/,… ; 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 diff --git a/skills/builder/templates/env.builder b/skills/builder/templates/env.builder index 2c4d641..f7d5307 100644 --- a/skills/builder/templates/env.builder +++ b/skills/builder/templates/env.builder @@ -10,4 +10,4 @@ # DOCKER_REGISTRY= # DOCKER_REPOSITORY= # IMAGE_TAG= -# PLATFORMS= +# PLATFORMS= # optional override; project ARCHES lives in makefile.builder diff --git a/skills/builder/templates/makefile.builder b/skills/builder/templates/makefile.builder index ea7e137..8e5a98a 100644 --- a/skills/builder/templates/makefile.builder +++ b/skills/builder/templates/makefile.builder @@ -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 diff --git a/tests/test_builder_check.py b/tests/test_builder_check.py index 74506c2..1b4d1fa 100644 --- a/tests/test_builder_check.py +++ b/tests/test_builder_check.py @@ -102,6 +102,13 @@ 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) def test_no_makefile_without_ready_is_usage_error(self) -> None: with tempfile.TemporaryDirectory() as temp: @@ -233,6 +240,172 @@ 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) + if __name__ == "__main__": unittest.main() From 2ba027098ffad3eafd7a6967da20d7a2f21b8e42 Mon Sep 17 00:00:00 2001 From: laily Date: Wed, 9 Sep 2026 01:13:56 +0800 Subject: [PATCH 10/10] =?UTF-8?q?feat(builder):=20Android/iOS=20=E6=9E=84?= =?UTF-8?q?=E5=BB=BA=E4=B8=8E=20copy=5Fartifact=20=E5=8F=91=E5=B8=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 APK/IPA 轨道和 copy_artifact.sh(本地目录或 container:/dir)。 push 必须委托该脚本,禁止在 makefile 里内联 docker cp。 --- skills/builder/README.md | 15 +- skills/builder/SKILL.md | 19 ++- skills/builder/references/contract.md | 29 +++- skills/builder/scripts/check.py | 147 ++++++++++++++--- skills/builder/scripts/copy_artifact.sh | 152 ++++++++++++++++++ skills/builder/templates/env.builder | 2 + .../templates/makefile.android.builder | 57 +++++++ skills/builder/templates/makefile.ios.builder | 57 +++++++ tests/test_builder_check.py | 58 +++++++ tests/test_builder_copy.py | 119 ++++++++++++++ 10 files changed, 618 insertions(+), 37 deletions(-) create mode 100755 skills/builder/scripts/copy_artifact.sh create mode 100644 skills/builder/templates/makefile.android.builder create mode 100644 skills/builder/templates/makefile.ios.builder create mode 100644 tests/test_builder_copy.py diff --git a/skills/builder/README.md b/skills/builder/README.md index a74b2af..e110f39 100644 --- a/skills/builder/README.md +++ b/skills/builder/README.md @@ -1,14 +1,15 @@ # 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 契约" - "看看项目现在的发布流程" @@ -50,6 +51,8 @@ DEB_SERVER_URL=https://deb.example.com DEB_REPOSITORY=main DEB_TOKEN= # 只放 .env.builder 或密钥系统,不进 git DOCKER_REGISTRY=registry.example.com +COPY_DEST=file-server:/srv +COPY_AS=latest.apk ``` 4. 日常发布就是两条命令: @@ -166,11 +169,12 @@ detached HEAD 用 `detached`,CI 可注入 `BUILD_BRANCH` / `CI_COMMIT_BRANCH` 用 builder 把当前 commit 的产物发布到项目已配置的仓库。 用 builder 发布多平台 linux/amd64,linux/arm64 镜像。 用 builder 把当前版本的 amd64 和 arm64 DEB 打出来并发布。 +用 builder 打一份测试 APK 并拷到 File Browser。 ``` ## Agent 会做什么 -1. 初始化:探测 DEB / Docker 轨道,写出 `makefile.builder` 和 `.env.builder` 骨架,按仓库证据填 `ARCHES`(不默认双架构),不改你已有的 `Makefile` 或 `.env`。 +1. 初始化:探测 DEB / Docker / Android / iOS / copy 轨道,写出 `makefile.builder` 和 `.env.builder` 骨架,按仓库证据填 `ARCHES`(不默认双架构),不改你已有的 `Makefile` 或 `.env`。 2. 检查:只跑 `scripts/check.py --ready`,不改文件。 3. 构建:走 `make -f makefile.builder`;只获构建授权时不走会自动上传的目标。 4. 发布:先校验产物,再上传。缺发布键、未授权、或可能覆盖同版本时停下问你。 @@ -196,6 +200,9 @@ Docker 只有你点名镜像时才走。token 不会写进输出、提交或回 | `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。 diff --git a/skills/builder/SKILL.md b/skills/builder/SKILL.md index 767c18c..00b492a 100644 --- a/skills/builder/SKILL.md +++ b/skills/builder/SKILL.md @@ -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,14 +21,14 @@ 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`:把 `/templates/makefile.builder` 拷到项目根。按轨道删掉未使用的 deb/docker/push* 段,把 `build` 的 TODO 换成仓库里已有的真实编译命令(可从用户 Makefile 抄配方,但不要 `include` 或递归调用它),且配方必须使用 `$(ARCH)`。按 [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`。不要改用户 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`:把 `/templates/env.builder` 拷到项目根(注释键,不含值)。缺发布键时在报告里给出可粘贴示例,并把 `.env.builder` 加入 `.gitignore`,不要提交。 6. 运行 `python3 -I -S /scripts/check.py --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 ``` ## 检查 @@ -61,5 +64,5 @@ DOCKER_REGISTRY=registry.example.com 2. 仅当用户明确要求发布、上传或提交时才上传。不猜测生产端点;不把 token 写入输出、文件、提交或回复;不用 `set -x` 跑含凭据的命令。可能覆盖同版本时先问。脏工作树默认拒绝发布;用户明确接受时设 `ALLOW_UNCOMMITTED=1` 并注明未提交修改。 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 `。多个包时不凭文件时间猜测。镜像轨道由 `publish_docker.sh` 自带远端 inspect。 -5. 发布优先 `make -f makefile.builder push[-deb|-docker]`,或直接调 `upload_deb.sh` / `publish_docker.sh`。未指定 `ARCH=` 时 `push-deb` 上传清单里每一个 `.deb`。脚本缺环境变量时加载 `.env.builder`(shell 显式值优先),不读 `.env`。不把 token 当命令行参数;不把脚本复制进项目。多平台镜像只能走 `publish_docker.sh`,不能拆进 make;不要把项目架构清单写进 `.env.builder`。 +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、工作区状态和未完成项。 diff --git a/skills/builder/references/contract.md b/skills/builder/references/contract.md index a89c26a..feff94a 100644 --- a/skills/builder/references/contract.md +++ b/skills/builder/references/contract.md @@ -36,23 +36,29 @@ make -f makefile.builder |------|------|------| | `deb` | 有 DEB 产物的项目 | 产出 `$(DIST_DIR)/__.deb`;只构建不上传。未在命令行/环境指定 `ARCH=` 且 `ARCHES` 多于一项时,按清单逐个递归构建;指定了 `ARCH=` 则只打那一个 | | `docker` | 有镜像的项目 | 构建本地单平台镜像 `linux/$(ARCH)`;**禁止 `--push`、禁止多平台** | +| `android` | 有 Android APK/AAB 的项目 | 可与 `build` 相同。产出 `$(DIST_DIR)/__.apk`(或 `.aab`);只构建不上传。`ARCHES` 是构建机 ISA;fat APK 只留本机一项 | +| `ios` | 有 iOS IPA 的项目 | 可与 `build` 相同。产出 `$(DIST_DIR)/__.ipa`;只构建不上传 | | `arches` | 可选(新模板必备) | 打印一行空格分隔的 `ARCHES`;无此目标视为存量 makefile,隐式单架构 | -| `push-deb` | 同时有 DEB 和镜像的项目 | 仅调 builder 的 `upload_deb.sh`。未指定 `ARCH=` 时上传 `ARCHES` 中每一项对应的 `.deb`;指定了则只上传那一个 | -| `push-docker` | 同时有 DEB 和镜像的项目 | 仅调 builder 的 `publish_docker.sh` | -| `push` | 单一产物类型时必备;双产物项目为聚合 | 依序调用对应 push-* 或直接调脚本;是发布的唯一 make 入口 | +| `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`。 未指定 `ARCH=` / `PLATFORMS` 时,`publish_docker.sh` 把 `ARCHES` 映射为 `linux/,…`。 -4. push 类目标必须是薄包装:解析脚本路径后委托,不内联 curl/token/端点。 +4. push 类目标必须是薄包装:解析脚本路径后委托,不内联 curl/token/端点/`docker cp`。 5. `build` 保持单架构,供 `deb` 循环调用。不要让 `build` 自己遍历 `ARCHES`。 + `build` 禁止 `docker cp`、`copy_artifact.sh`、curl/scp。 ## 2. 变量 @@ -61,7 +67,7 @@ make -f makefile.builder | `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。 @@ -124,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 轨道 | 变量 | 必填 | 说明 | diff --git a/skills/builder/scripts/check.py b/skills/builder/scripts/check.py index 6b8a411..ae7477a 100755 --- a/skills/builder/scripts/check.py +++ b/skills/builder/scripts/check.py @@ -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" @@ -285,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)) @@ -316,24 +355,42 @@ SCRIPT_RESOLVE_SNIPPETS = tuple( def check_push_delegates( - report: Report, project: Path, dual_artifact: bool, deb_project: bool + report: Report, + project: Path, + *, + deb_project: bool, + docker_project: bool, + copy_project: bool, ) -> None: - targets = ("push-deb", "push-docker") if dual_artifact else ("push",) + 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) @@ -342,7 +399,7 @@ def check_push_delegates( problems.append("missing targets: " + ", ".join(missing)) problems.extend(inline) if deb_project: - push_deb_target = "push-deb" if dual_artifact else "push" + 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: @@ -385,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_]*)=(.*)$") @@ -416,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 = [] @@ -436,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, @@ -445,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: @@ -469,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( [ @@ -585,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) @@ -596,6 +682,9 @@ 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) @@ -621,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, deb_project) + 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 diff --git a/skills/builder/scripts/copy_artifact.sh b/skills/builder/scripts/copy_artifact.sh new file mode 100755 index 0000000..ba8a5ec --- /dev/null +++ b/skills/builder/scripts/copy_artifact.sh @@ -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" diff --git a/skills/builder/templates/env.builder b/skills/builder/templates/env.builder index f7d5307..76e41a0 100644 --- a/skills/builder/templates/env.builder +++ b/skills/builder/templates/env.builder @@ -11,3 +11,5 @@ # DOCKER_REPOSITORY= # IMAGE_TAG= # 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 diff --git a/skills/builder/templates/makefile.android.builder b/skills/builder/templates/makefile.android.builder new file mode 100644 index 0000000..c84894b --- /dev/null +++ b/skills/builder/templates/makefile.android.builder @@ -0,0 +1,57 @@ +# Builder 契约文件。放到项目根,用 `make -f makefile.builder `。 +# Android 轨道:build 产出 dist/__.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) diff --git a/skills/builder/templates/makefile.ios.builder b/skills/builder/templates/makefile.ios.builder new file mode 100644 index 0000000..6add0bb --- /dev/null +++ b/skills/builder/templates/makefile.ios.builder @@ -0,0 +1,57 @@ +# Builder 契约文件。放到项目根,用 `make -f makefile.builder `。 +# iOS 轨道:build 产出 dist/__.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) diff --git a/tests/test_builder_check.py b/tests/test_builder_check.py index 1b4d1fa..2865642 100644 --- a/tests/test_builder_check.py +++ b/tests/test_builder_check.py @@ -109,6 +109,9 @@ class BuilderCheckTests(unittest.TestCase): 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: @@ -406,6 +409,61 @@ class BuilderCheckTests(unittest.TestCase): 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() diff --git a/tests/test_builder_copy.py b/tests/test_builder_copy.py new file mode 100644 index 0000000..9165314 --- /dev/null +++ b/tests/test_builder_copy.py @@ -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()