8 Commits

51 changed files with 2574 additions and 1033 deletions
+31 -40
View File
@@ -34,67 +34,60 @@ skiff 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
kits/
└── agent-collaboration-kit/ # 可复制到项目的协作规范包,不是 skill
skiff/ # CLI 源码(Python 3)
bin/skiff # CLI 入口
registry.yaml # 外部 Git skill 来源目录
catalog.yaml # skiff 预置 Skill 来源目录
AGENTS.md # 本文档
```
**本仓库包含**:`skills/`、`kits/`、`skiff/`、`bin/skiff`、`registry.yaml`、`AGENTS.md`
**本仓库包含**:`skills/`、`skiff/`、`bin/skiff`、`catalog.yaml`、`AGENTS.md`
**本仓库不包含**:各项目的 skill 启用清单
---
## Skill 目录
### 自研(Owned)
### 内置(Builtin)
| Skill | 说明 |
| ---------------------------------------------------------------------- | ------------------------------------------------- |
| [skiff](skills/skiff/SKILL.md) | 本项目工作流:创建、使用、反馈与更新 owned skill |
| [ack](skills/ack/SKILL.md) | ACK 入口:显式初始化、检查并运行项目三角色协作闭环 |
| [skiff](skills/skiff/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:复制 `skills/_template/` → `skills/<name>/`,编辑 `SKILL.md`,在本仓库 commit。
### 规范包(Kits,不是 Skill)
Skill 需要的稳定规范、模板、示例和脚本直接放在自己的目录中,例如
`skills/ack/references/`、`templates/`、`examples/` 和 `scripts/`。项目初始化只生成
项目状态,不复制或链接 Skill 内容:
`kits/` 放可复制到项目的流程规范、模板、检查清单和示例资料。它们不是 Agent Skill:
```bash
skiff init ack
```
- 不放 `SKILL.md` frontmatter
- 不由 `skiff install` / `skiff add` 安装
- 不会被 Agent 自动触发加载
- 使用方式是复制到目标项目文档目录,或在目标项目 `AGENTS.md` 中引用
### 预置目录(Catalog)
| Kit | 说明 | 推荐落地位置 |
| --- | --- | --- |
| [agent-collaboration-kit](kits/agent-collaboration-kit/README.md) | 多 Agent 协作闭环规范包 | `docs/agent-collaboration-kit/` |
新建规范包:创建 `kits/<name>/README.md`,写清适用场景、复制到项目后的推荐目录、需要项目填充的占位符,并在本仓库 commit。
### 外部(External Git)
在 `registry.yaml` 中注册,通过 skiff 拉取安装:
在 `catalog.yaml` 中预置,通过 skiff 拉取安装:
| Skill | 来源 |
| ----------- | -------------------------------------------------------------------------- |
| superpowers | [https://github.com/obra/superpowers](https://github.com/obra/superpowers) |
| Source | 来源 |
| --- | --- |
| waza | [https://github.com/tw93/Waza](https://github.com/tw93/Waza) |
```bash
skiff fetch superpowers
skiff install-external superpowers
skiff fetch waza
skiff add waza/think -g
```
### 自定义仓库(Custom Sources)
@@ -135,8 +128,8 @@ npx skills find typescript
1. **SSOT** — 自研 skill 只存在于 `skills/<name>/`,不在 Agent 目录直接创建
2. **项目自治** — 每个项目自己维护 `.skills.yaml`,本仓库不维护项目清单
3. **软链优先** — 通过 symlink 映射到 Agent 目录,改 skill 即改 SSOT
4. **类型分离** — `skills/` 只放可安装 skill;非 skill 的规范包、模板包放 `kits/`
5. **一体维护** — skill、规范包与 CLI 同仓库维护;需要时再与独立 skiff 仓库同步
4. **能力内聚** — Skill 使用的规范、模板和脚本与 `SKILL.md` 同目录维护
5. **一体维护** — skill 与 CLI 同仓库维护;需要时再与独立 skiff 仓库同步
---
@@ -145,9 +138,8 @@ npx skills find typescript
```
skills 仓库(本仓库) skiff CLI
skills/<name>/ ←── skiff install / enable
kits/<name>/ ←── 手动复制 / 项目文档引用
skiff/ ←── python3 -m skiff
registry.yaml ←── skiff add / fetch
catalog.yaml ←── skiff add / fetch
↑
~/.skills(symlink)
│
@@ -163,8 +155,8 @@ registry.yaml ←── skiff add / fetch
| 层级 | 位置 | 维护方式 |
| ---------------- | ---------------------------------- | ------------------------------- |
| **Owned** | `skills/<name>/` | 本仓库 commit |
| **External Git** | `~/.local/share/skills/externals/` | `skiff fetch` |
| **Builtin** | `skills/<name>/` | 本仓库 commit |
| **Catalog** | `catalog.yaml` + checkout 缓存 | `skiff catalog add / fetch` |
| **Custom Source** | `~/.local/share/skiff/sources/` 或本地路径 | `skiff source add/fetch` |
| **External NPM** | `node_modules/` | `npx skills add` / `skills-npm` |
@@ -172,7 +164,6 @@ registry.yaml ←── skiff add / fetch
| 类型 | 位置 | 维护方式 |
| --- | --- | --- |
| **Kit** | `kits/<name>/` | 本仓库 commit;复制到目标项目 `docs/` 或由项目 `AGENTS.md` 引用 |
| **OpenSpec 配置** | `openspec/` | 本仓库 commit;服务于本仓库自身的规格流程 |
@@ -195,10 +186,10 @@ registry.yaml ←── skiff add / fetch
```yaml
# .skills.yaml(在项目根目录)
skills:
- declarative-openspec-loop
- name: superpowers
source: registry
ref: main
- name: declarative-openspec-loop
source: builtin
- name: think
source: catalog:waza
- name: internal-review
source: company
@@ -240,9 +231,9 @@ skiff sync
| `skiff bootstrap` | 将 skiff 项目 skill 全局安装到所有 Agent |
| `skiff list` | 列出所有 skill |
| `skiff status` | 安装状态总览 |
| `skiff install <name>` | 全局安装(symlink) |
| `skiff uninstall <name>` | 移除 symlink |
| `skiff add / fetch / install-external` | 外部 Git skill |
| `skiff add <name> [-g]` | 项目或全局安装(symlink) |
| `skiff remove <name> [-g]` | 移除 symlink |
| `skiff catalog add` / `skiff fetch` | 管理和拉取 catalog source |
### 草稿与健康检查
+32 -28
View File
@@ -9,7 +9,7 @@ git clone https://git.yumee.top/laily/skills.git ~/.skills
cd ~/.skills
./install.sh # 安装 CLI,并将 skiff 项目 skill 安装到所有 Agent
skiff install declarative-openspec-loop
skiff add declarative-openspec-loop -g
skiff select # 交互式选择并批量安装
skiff list
skiff status
@@ -19,36 +19,34 @@ skiff status
```
skills/ # 自研 skill(SSOT):每个子目录必须有 SKILL.md
kits/ # 可复制到项目的规范包 / 模板包,不是 Agent Skill
skiff/ # CLI 源码(Python 3)
bin/skiff # CLI 入口
registry.yaml # 外部 Git skill 目录
catalog.yaml # skiff 预置 Skill 来源目录
AGENTS.md # 详细规范与架构说明
```
| 路径 | 说明 |
|------|------|
| [skills/](skills/) | 自研 skill,每个子目录含 `SKILL.md` |
| [kits/](kits/) | 项目规范包、协作模板、可复制资料,不由 skiff 安装 |
| [skills/](skills/) | 自研 skill,每个子目录含 `SKILL.md`,可附带 references、templates 和 scripts |
| [skiff/](skiff/README.md) | 安装、软链、健康检查 CLI |
| [registry.yaml](registry.yaml) | 外部 Git skill 注册表 |
| [catalog.yaml](catalog.yaml) | skiff 预置 Skill 来源目录 |
| [AGENTS.md](AGENTS.md) | 设计原则、编写规范、架构详解 |
## 自研 Skill
| Skill | 说明 |
|-------|------|
| [skiff](skills/skiff/SKILL.md) | 在项目中创建、安装、反馈和维护 owned skill |
| [ack](skills/ack/SKILL.md) | 显式初始化、检查并运行 ACK 三角色协作闭环 |
| [skiff](skills/skiff/SKILL.md) | 在项目中创建、安装、反馈和维护 builtin skill |
| [declarative-openspec-loop](skills/declarative-openspec-loop/SKILL.md) | 声明式编程循环:用户提供校验方式,Agent 自动迭代直到通过 |
| [discussion-notes](skills/discussion-notes/SKILL.md) | 讨论沉淀:边讨论边维护 Markdown 笔记 |
## 规范包(不是 Skill)
ACK 是包含规范、模板与校验脚本的完整 Skill。安装 Skill 后可初始化当前项目状态:
`kits/` 用来维护可复制到项目里的规范说明、模板和检查清单。它们没有 `SKILL.md`,不会被 `skiff install`、`skiff add` 或 Agent 自动加载。
| Kit | 说明 |
|-----|------|
| [agent-collaboration-kit](kits/agent-collaboration-kit/README.md) | 多 Agent 协作闭环规范包,复制到项目 `docs/agent-collaboration-kit/` 后按项目情况填写 |
```bash
skiff init ack
skiff init ack --project ~/app
```
新建 skill:
@@ -67,8 +65,8 @@ skiff add my-skill -g # 全局安装验证
### 全局(用户级)
```bash
skiff install <name> # 安装到 ~/.cursor/skills/ 等
skiff install <name> --target cursor
skiff add <name> -g # 安装到 ~/.cursor/skills/ 等
skiff add <name> -g -a cursor
```
### 项目级
@@ -77,10 +75,10 @@ skiff install <name> --target cursor
```yaml
skills:
- declarative-openspec-loop
- name: superpowers
source: registry
ref: main
- name: declarative-openspec-loop
source: builtin
- name: think
source: catalog:waza
targets: # 可选,默认 all
- cursor
@@ -89,18 +87,25 @@ targets: # 可选,默认 all
```
```bash
skiff enable declarative-openspec-loop
skiff add declarative-openspec-loop
skiff sync
skiff disable declarative-openspec-loop
skiff remove declarative-openspec-loop
```
## 外部 Skill
## Catalog 与 Custom Source
**Git 来源**(经 skiff 管理):
安装 catalog 中预置的来源:
```bash
skiff fetch superpowers
skiff install-external superpowers
skiff fetch waza
skiff add waza/think -g
```
接入团队自己的本地目录或 Git 仓库:
```bash
skiff source add company --local ~/code/company-skills --skills-path skills
skiff add company/internal-review -g
```
**社区来源**(Vercel CLI):
@@ -115,8 +120,7 @@ npx skills find typescript
```
本仓库
├── skills/<name>/ ←── skiff install / enable
├── kits/<name>/ ←── 手动复制 / 项目文档引用
├── registry.yaml ←── skiff add / fetch
├── catalog.yaml ←── 预置来源发现与 fetch
└── skiff/ ←── python3 -m skiff
↑
~/.skills(symlink)
@@ -133,7 +137,7 @@ npx skills find typescript
1. **SSOT** — 自研 skill 只存在于 `skills/<name>/`
2. **项目自治** — 各项目自行维护 `.skills.yaml`
3. **软链优先** — 通过 symlink 映射到 Agent 目录,改 skill 即改 SSOT
4. **规范包分离** — 非 skill 的流程规范、模板包放在 `kits/<name>/`,不混入 `skills/`
4. **能力内聚** — Skill 所需规范、模板和脚本与 `SKILL.md` 放在同一目录
5. **一体维护** — skill 与 CLI 同仓库,Python 3 直接运行,无需编译
## 文档
+1 -1
View File
@@ -1,4 +1,4 @@
# External skills registry
# Preconfigured Skill source catalog
# Format:
# <name>:
# repo: <git-url>
-175
View File
@@ -1,175 +0,0 @@
# ack — Agent Collaboration Kit
`ack`(Agent Collaboration Kit)是一套可复用到其它项目的多 Agent 协作规范,默认三个独立角色:**Coordinator (PM) 拆解需求并调度闭环,Developer 实现与白盒验证,Test 独立黑盒复测**。关键属性是验证者 ≠ 实现者。角色默认按成本分层:Coordinator 用强模型(且不亲自跑测试),Test/Developer 用中低模型(见 `core/model-routing.md`)。
当前版本见 `VERSION`。这不是 Agent Skill(无 `SKILL.md`),不由 skiff 安装,而是复制/引用到目标项目。
---
## 适用场景
- 多个 Agent 分工协作,而非单个 Agent 从需求写到代码。
- 需要区分规格、测试、实现、复测的责任边界(Coordinator / Test / Developer 三角色)。
- 需要把失败项派发给 Developer,`worker_done` 后由独立的 Test 复测、Coordinator 终检。
- 需要连续修复多个问题,并把三轮仍未修好的问题留档。
---
## 目录结构
```text
ack/
VERSION # kit 版本,接入时记入项目
README.md
init-new-project.md # 给 Agent 的新项目初始化手册
adoption-checklist.md
core/ # 稳定核心:跨项目通用,随 kit 升级,尽量别改
kickoff.md # 如何开始一个需求(启动手册 + 开场指令模板)
roles-and-permissions.md # 角色/权限/状态机/完成定义(SSOT)
model-routing.md # 三角色默认模型档位 + 升级规则(SSOT)
closed-loop.md # 编排无关闭环 + 手动模式 + worktree 对齐
optimization-method.md # 验收信号 + 三轮失败策略(SSOT)
prompt-templates.md # 派发 prompt 模板
orca-adapter.md # Orca 具体命令(一种编排实现,可选)
templates/ # 项目覆盖层:复制一次并填空
project.template.md # 项目覆盖层模板(文件名可配置,默认不占用 AGENTS.md)
tasks.template.yaml
tasks.schema.json # 任务板权威结构(跨语言)
examples/ # 填好的最小可跑示例
project.example.md
tasks.example.yaml
scripts/
validate_tasks.py # 校验 tasks.yaml(参考实现)
```
**核心原则**:`core/` 是稳定核心,每个概念只定义一次;`templates/` 是项目覆盖层。二者分离,让 kit 升级和项目定制互不干扰。
---
## 项目目录布局(推荐)
每个项目在 `docs/ack/` 下只放**一个软链接**加两个项目文件,稳定核心全部走软链接:
```text
<project>/docs/ack/
kit -> <此框架目录> # 唯一软链接:core/templates/examples/scripts 全在里面
project.md # 项目覆盖层(差异),可改名,见下
tasks.yaml # 项目任务板
```
引用稳定核心时统一走 `kit/` 前缀,例如 `docs/ack/kit/core/roles-and-permissions.md`。
这样「一个项目一个软链接」,kit 升级自动生效,项目定制(`project.md` + `tasks.yaml`)互不干扰。
## 分发方式(二选一)
### 方式 A:单软链接引用(推荐,可升级)
把本框架目录整体 symlink 成项目的 `docs/ack/kit`:
```bash
mkdir -p <project>/docs/ack
ln -s <此框架绝对路径> <project>/docs/ack/kit
```
也可用 git submodule / sparse checkout 达到同样效果。项目只维护 `project.md` 与 `tasks.yaml`。
### 方式 B:整份复制(简单,手动升级)
把本框架整目录复制成 `docs/ack/kit/`(或直接铺平到 `docs/ack/`,此时引用去掉 `kit/` 前缀)。**务必**在 `project.md` 与 `tasks.yaml` 的 `kitVersion` 记录来源版本,日后照 `VERSION` diff 升级。
### 覆盖层文件名(重要)
项目差异(路径、命令、handle、模型档位)写在**项目覆盖层文件**里,**默认 `docs/ack/project.md`,不占用 `AGENTS.md`**,以免与团队已有的 `AGENTS.md` 约定冲突。文件名可配置:想让 Agent 自动加载,可在 `AGENTS.md` 加一行指向它,或直接命名为 `AGENTS.md`。无论叫什么,都在 `tasks.yaml` 的 `project.overlayFile` 记录实际路径。覆盖层只填差异,不复制 `core/` 内容。
---
## 快速接入
让 Agent 在新项目执行初始化时,优先给它 `init-new-project.md`。
1. 建 `docs/ack/`,按方式 A 建单软链接 `kit`。
2. 复制 `kit/templates/project.template.md` → `docs/ack/project.md`,填项目差异与模型档位,记 `kitVersion`。
3. 复制 `kit/templates/tasks.template.yaml` → `docs/ack/tasks.yaml`,填 `project`(含 `overlayFile`)与首个任务。
4. 跑 `python3 docs/ack/kit/scripts/validate_tasks.py docs/ack/tasks.yaml` 确认结构。
5. 按 `kit/core/closed-loop.md` 跑闭环(Orca 见 `kit/core/orca-adapter.md`,无 Orca 用手动模式)。
6. 逐项对照 `kit/adoption-checklist.md`。
参考 `examples/` 里填好的 `project.example.md` 与 `tasks.example.yaml`。
---
## 常用使用方法
### 开一个新需求(最常用)
你(强模型会话)就是 Coordinator(PM)。完整手册见 `core/kickoff.md`,开场指令:
```text
我要做一个新需求:<一句话需求>。你作为 ack 的 Coordinator(PM),按 docs/ack/kit/core 规范:
先读 project.md 与 core/*;写产品文档到 docs/ 并把需求拆成带可观测验收信号的任务写进 docs/ack/tasks.yaml;
先给我确认产品文档+任务拆分,再按 project.md 优先复用空闲 DEV/TEST worker,没有时才校验并创建,核对实际模型后循环派发/复测/终检;
每个任务最多三轮,三轮不过记 leftover。
```
### 复用或启动 worker
先按 `core/orca-adapter.md` §「解析并复用 worker」运行 `terminal list` 和活跃任务查询。只有没有同 worktree、同角色、配置兼容的空闲 worker 时,才执行下面的创建命令:
```bash
DEV_CMD='codex --dangerously-bypass-approvals-and-sandbox -m gpt-5.6-terra -c model_reasoning_effort=medium'
TEST_CMD='codex --dangerously-bypass-approvals-and-sandbox -m gpt-5.6-luna -c model_reasoning_effort=low'
python3 docs/ack/validate_worker_command.py --role developer --command "$DEV_CMD"
python3 docs/ack/validate_worker_command.py --role test --command "$TEST_CMD"
orca terminal create --worktree active --command "$DEV_CMD" --title "ACK-DEV-CODEX-TERRA-1" --json
orca terminal create --worktree active --command "$TEST_CMD" --title "ACK-TEST-CODEX-LUNA-1" --json
# Cursor worker 使用同一项目校验器
CURSOR_CMD='cursor-agent --yolo --model auto'
python3 docs/ack/validate_worker_command.py --role developer --command "$CURSOR_CMD"
```
派发成功后把实际 handle 写入任务的 `dispatch.worker`,下次优先复用。需要隔离/并行时先建 worktree:`orca worktree create --name <feature> --base-branch <base> --json`(选择依据见 `core/closed-loop.md`)。
### 派发与等待(Orca)
```bash
orca orchestration task-create --spec "<任务与验收>" --json
orca orchestration dispatch --task <task_id> --to <dev_handle> --json
orca orchestration check --terminal <coordinator_handle> --wait \
--types worker_done,retest_result,escalation,decision_gate --timeout-ms 900000 --json
```
不能 `--inject` 时手动投递 `core/prompt-templates.md` 的模板;命令细节见 `core/orca-adapter.md`。
### 校验任务板结构
```bash
python3 docs/ack/kit/scripts/validate_tasks.py docs/ack/tasks.yaml
```
### 只处理已有未通过项
用「默认口令」(见下)让 Coordinator 直接扫 `tasks.yaml` 里的 `open` / `failed_retest` 继续闭环。
### 速查:想做什么 → 看哪个文件
| 想做的事 | 文件 |
|----------|------|
| 开一个新需求怎么起步 | `core/kickoff.md` |
| 角色/权限/状态机/完成定义 | `core/roles-and-permissions.md` |
| 三角色各自该怎么做(规划/写测试/复测能力清单) | `core/roles-and-permissions.md` §三角色能力清单 |
| 用哪个模型、怎么固定、何时升级 | `core/model-routing.md` |
| 闭环步骤、手动模式、worktree 决策与对齐 | `core/closed-loop.md` |
| 验收信号写法、三轮失败策略 | `core/optimization-method.md` |
| 派发/复测/回报的 prompt 文案 | `core/prompt-templates.md` |
| Orca 具体命令 | `core/orca-adapter.md` |
| 让 Agent 在新项目初始化 ack | `init-new-project.md` |
| 新项目接入步骤 | `adoption-checklist.md` |
---
## 默认口令
```text
用三角色协作闭环处理 docs/ack/tasks.yaml 里的未通过项:Coordinator 派发给 Developer 修复,再交给 Test 独立复测并终检;每个问题最多修三轮,三轮仍不过就记录为遗留,然后继续下一个。
```
-71
View File
@@ -1,71 +0,0 @@
# 接入清单
把本工具包引用/复制到新项目后,按此清单接入。术语与规则详见 `core/`。
## 1. 分发与版本
- [ ] 选定分发方式(README §「分发方式」A 单软链接 / B 复制)。
- [ ] 在 `docs/ack/` 下建单软链接 `kit`(指向本框架)。
- [ ] 覆盖层文件与 `tasks.yaml` 记录 `kitVersion`(对齐 `kit/VERSION`)。
## 2. 项目级规范(覆盖层)
- [ ] 由 `kit/templates/project.template.md` 生成覆盖层文件(默认 `docs/ack/project.md`,**不占用 `AGENTS.md`**)。
- [ ] 在 `tasks.yaml` 的 `project.overlayFile` 记录覆盖层实际路径。
- [ ] 覆盖层引用 `docs/ack/kit/core/`,不复制其内容。
- [ ] 填清项目简介、技术栈、运行/构建/单测/集成测试命令。
- [ ] 若要 Agent 自动加载覆盖层:在 `AGENTS.md` 加一行指向它,或直接命名为 `AGENTS.md`(可选)。
## 2b. 模型档位
- [ ] Coordinator 默认强模型且不亲自跑测试。
- [ ] Test、Developer 默认中低模型。
- [ ] 记录升级规则:三轮失败升级 Coordinator 复盘;复杂实现升级 Developer 档位(见 `kit/core/model-routing.md`)。
## 3. 路径权限
- [ ] 填实际的 Coordinator (PM) 可写路径(规格)。
- [ ] 填实际的 Test 可写路径(集成测试、复测记录)。
- [ ] 填实际的 Developer 可写路径(源码、单元测试、配置模板)。
- [ ] 本地私有配置标记只读或不提交。
- [ ] 确认 `tasks.yaml` 只有 Coordinator 写(见 `kit/core/roles-and-permissions.md`)。
## 4. 任务板
- [ ] 由 `kit/templates/tasks.template.yaml` 生成 `docs/ack/tasks.yaml`。
- [ ] 替换 `<project_name>`、`<repo_path>`、`<base_url>`、`<dev_worktree>`、`overlayFile`。
- [ ] 至少加一个真实任务,验收写成可观测信号(见 `kit/core/optimization-method.md` §1)。
- [ ] 跑 `python3 docs/ack/kit/scripts/validate_tasks.py docs/ack/tasks.yaml` 通过。
## 5. 编排
- [ ] 决定用 Orca(`kit/core/orca-adapter.md`)还是手动模式(`kit/core/closed-loop.md`)。
- [ ] Orca:`orca status`、`terminal list` 可用,Coordinator / Developer / Test 三个终端都在,回报能发回 Coordinator。
- [ ] 派发前会先查空闲 worker:按 `ACK-<ROLE>-<CLI>-<TIER>-<N>` 复用同 worktree、同角色、同配置终端;仅在不存在或都 busy 时新建,并把实际 handle 写入 `dispatch.worker`。
- [ ] 决定子任务放当前 worktree 起子 agent,还是新建隔离 worktree(决策速查见 `kit/core/closed-loop.md` §「子任务放哪」)。
- [ ] 多 worktree 时确认 `tasks.yaml`(SSOT)只保留一个权威副本,由 Coordinator 单写。
## 6. 验证命令
- [ ] Test 有黑盒复测命令,Developer 有白盒验证命令。
- [ ] 构建产物与服务启动命令写清。
- [ ] 浏览器测试 `BASE_URL` 写清,复测前能确认服务来自正确 worktree。
## 7. 三轮失败策略
- [ ] 每个任务记录 `dispatch.rounds`。
- [ ] 三轮失败标记 `leftover` 并填 `resolution.leftoverReason`。
- [ ] `leftover` 不阻塞其它任务;最终报告列出所有 `leftover` 与最后失败证据。
## 8. 第一次试运行
选一个低风险 bug 跑完整闭环,结束后检查是否出现:
- Developer 修改了越权路径,或 Test 改了源码。
- worker_done 或复测报告没有验证证据。
- Test 复测到了旧服务。
- Coordinator 没做终检就直接 verified。
- 任务板字段不够记录失败原因。
- 三轮失败策略没被执行。
把发现的问题回写到项目覆盖层文件;若属通用问题,回流到 kit 的 `kit/core/` 并升 `VERSION`。
-183
View File
@@ -1,183 +0,0 @@
# 在新项目初始化 ack(给 Agent 的操作手册)
本文件指导 Agent 把 `ack` 接入一个目标项目。它是**执行手册**:按步骤在目标项目创建 `docs/ack/`、挂载 kit、生成项目覆盖层和任务板,并完成一次结构校验。
若只想人工核对是否接入完整,看 `adoption-checklist.md`。若要开始一个真实需求,看 `core/kickoff.md`。
---
## 0. 前提与安全边界
开始前先确认三件事:
1. 目标项目根目录:运行或读取当前环境的项目根,不要假设路径。
2. ack kit 目录:本框架目录的绝对路径,例如 `<ack_kit_path>`。
3. 分发方式:默认用**单软链接引用**,只有用户明确要求时才整份复制。
硬规则:
- 不要覆盖已有 `docs/ack/project.md`、`docs/ack/tasks.yaml` 或团队现有 `AGENTS.md`。如果文件已存在,先读内容并向用户说明差异,再询问是否合并。
- 不要把 `core/` 内容复制进项目覆盖层。项目覆盖层只写项目差异。
- 不要提交、推送或改 git 配置,除非用户明确要求。
- 不要把私有配置、token、`.env` 内容写进 `tasks.yaml` 或 `project.md`。
---
## 1. 建目录并挂载 kit
在目标项目根目录下创建推荐布局:
```text
docs/ack/
kit -> <ack_kit_path>
project.md
tasks.yaml
```
默认方式(推荐,可升级):
```bash
mkdir -p docs/ack
ln -s <ack_kit_path> docs/ack/kit
```
如果 `docs/ack/kit` 已存在:
- 是指向同一个 ack 目录的软链接:继续。
- 是指向旧版本的软链接:报告当前指向与目标指向,询问是否更新。
- 是普通目录:不要删除;询问用户是保留、备份还是改用复制模式。
复制模式(仅用户明确要求):
```bash
mkdir -p docs/ack
cp -R <ack_kit_path> docs/ack/kit
```
复制模式接入后,后续升级需要人工对比 `docs/ack/kit/VERSION`。
---
## 2. 生成项目覆盖层 `project.md`
从模板生成:
```bash
cp docs/ack/kit/templates/project.template.md docs/ack/project.md
```
然后填实际项目差异:
- 项目名、技术栈、运行命令、Base URL。
- 覆盖层文件路径:默认 `docs/ack/project.md`。
- 三角色模型档位:默认 Coordinator 强模型,Developer/Test 中低模型。
- 路径权限:规格文档、集成测试、复测记录、源码、单元测试、配置模板、本地私有配置。
- Developer 白盒验证命令:单测、构建、服务启动或重启。
- Test 黑盒复测命令:preflight、API smoke、浏览器回归。
如果是网站项目,务必在命令里写清:
- Developer 改完后如何重启服务或确认热更新生效。
- Test 使用哪个 Base URL。
- Test 浏览器复测入口、用例目录或手工检查路径。
可选:若希望 Agent 自动加载覆盖层,在目标项目 `AGENTS.md` 加一行指向它:
```markdown
本项目使用 ack 三角色协作协议,项目覆盖层见 `docs/ack/project.md`。
```
如果目标项目已有 `AGENTS.md`,只追加这一行或一个短小段落;不要重写原文件。
---
## 3. 生成任务板 `tasks.yaml`
从模板生成:
```bash
cp docs/ack/kit/templates/tasks.template.yaml docs/ack/tasks.yaml
```
至少替换这些字段:
- `updatedAt`:当前时间,带时区。
- `kitVersion`:读取 `docs/ack/kit/VERSION`。
- `project.name`:项目名。
- `project.repoPath`:目标项目根目录绝对路径。
- `project.baseUrl`:网站或 API 的默认访问地址;非服务项目可写 `n/a`。
- `project.devWorktree`:默认当前 worktree;如果会用独立 worktree,写实际路径。
- `project.overlayFile`:默认 `docs/ack/project.md`。
任务可以先保留一个低风险示例任务,但更推荐写入真实首个任务。每个任务的验收必须是可观测信号:
- 可见文本:页面或 CLI 输出出现什么。
- API 结果:状态码、字段、响应值。
- 交互结果:点击、跳转、提交、取消后的真实状态。
不要只写“功能正常”“体验更好”。
---
## 4. 校验结构
在目标项目根目录运行:
```bash
python3 docs/ack/kit/scripts/validate_tasks.py docs/ack/tasks.yaml
```
通过后继续;失败则按错误修正 `tasks.yaml`,不要绕过校验。
同时人工检查:
- `docs/ack/kit` 是否能访问 `core/`、`templates/`、`scripts/`。
- `docs/ack/project.md` 是否只写项目差异,没有复制大段 core 文本。
- `docs/ack/tasks.yaml` 的 `project.overlayFile` 是否指向真实文件。
- 路径权限是否能防止 Developer 改规格、Test 改源码、多人写 `tasks.yaml`。
- 网站项目是否写清服务重启和浏览器复测方式。
---
## 5. 给用户的初始化报告
初始化完成后,Agent 应给用户一段短报告:
```text
ack 初始化完成。
创建/确认:
- docs/ack/kit -> <ack_kit_path>
- docs/ack/project.md
- docs/ack/tasks.yaml
已填项目差异:
- 技术栈:<...>
- Developer 验证命令:<...>
- Test 复测命令:<...>
- Base URL:<...>
校验:
- python3 docs/ack/kit/scripts/validate_tasks.py docs/ack/tasks.yaml: passed
下一步:
- 用 docs/ack/kit/core/kickoff.md 开一个真实需求;或
- 选一个低风险 bug 跑一次完整三角色闭环。
```
如果有未完成项,不要说“初始化完成”。改用:
```text
ack 初始化已部分完成,阻塞在:<具体原因>。
需要用户决定:<选项或缺失信息>。
```
---
## 6. 一句话口令
当用户在新项目里说“初始化 ack”时,Agent 可以按下面执行:
```text
请在当前项目初始化 ack:确认项目根和 ack kit 路径;创建 docs/ack/kit 软链接;由 kit/templates 生成 docs/ack/project.md 与 docs/ack/tasks.yaml;填入项目实际路径、命令、Base URL、模型档位和首个任务;运行 validate_tasks.py;不要覆盖已有文件,不要提交或推送,完成后报告创建文件、校验结果和下一步。
```
+25 -20
View File
@@ -14,7 +14,7 @@ cd /path/to/skills # 本仓库根目录
## 命令风格
接口对齐 [Vercel skills CLI](https://github.com/vercel-labs/skills) 的 `add` / `remove`,
统一管理 **~/.skills 自研 skill**、命名 custom source 和 registry 外部 skill。
统一管理 builtin skill、预置 catalog source 和用户命名的 custom source。
```bash
# 浏览可用自研 skill
@@ -65,15 +65,17 @@ skiff bootstrap
| 命令 | 说明 |
|------|------|
| `skiff bootstrap` | 将本项目的 `skiff` skill 全局安装到所有 Agent |
| `skiff update` | 在 `~/.skills` 执行 `git pull`,更新 skiff 自身 |
| `skiff init <name> [--project DIR]` | 使用 builtin skill 自带模板初始化项目状态 |
### 全局安装(自研 skill)
### Skill 安装
| 命令 | 说明 |
|------|------|
| `skiff add <name> [--global] [-a AGENT...] [-y]` | 安装到 Agent 目录(软链) |
| `skiff select [--global] [-a AGENT...]` | 打开终端多选界面,批量安装 skill |
| `skiff remove <name> [--global] [-a AGENT...] [-y]` | 移除软链(`rm` / `r` 别名) |
| `skiff add --list` | 列出可用自研 skill |
| `skiff add --list` | 列出可用 builtin skill |
| `skiff publish [paths] -m MSG [--push]` | 在 ~/.skills 内 git add/commit/push |
旧命令 `install` / `uninstall` 已移除,请改用 `add` / `remove`。
@@ -86,16 +88,16 @@ skiff bootstrap
| claude | `~/.claude/skills/` |
| codex | `~/.codex/skills/` |
### 外部 Git skill
### 预置 Catalog Source
| 命令 | 说明 |
|------|------|
| `skiff registry add <name> <repo-url> [--ref main] [--path .]` | 写入 `registry.yaml` |
| `skiff fetch <name>` | 克隆或更新外部仓库缓存 |
| `skiff add <name> [-g] [-a AGENT...]` | 安装 registry 中的单个 skill 或完整 collection(缺失时自动 fetch) |
| `skiff catalog add <name> <repo-url> [--ref main] [--path .]` | 写入 `catalog.yaml` |
| `skiff fetch <name>` | 克隆或更新 catalog source checkout |
| `skiff add <name> [-g] [-a AGENT...]` | 安装 catalog 中的单个 skill 或完整 collection(缺失时自动 fetch) |
| `skiff add <collection>/<skill> [...]` | 只安装 collection 中指定的 skill |
`registry.yaml` 条目可额外提供 `description` 和 `tags`。`description`
`catalog.yaml` 条目可额外提供 `description` 和 `tags`。`description`
会显示在 `skiff select` 的候选列表中。`path` 可以直接指向含
`SKILL.md` 的单个 skill,也可以指向由多个 skill 目录组成的 collection。
collection 会自动发现下一层所有含 `SKILL.md` 的目录;`skiff add <name>`
@@ -112,7 +114,10 @@ skiff select --project ~/code/app # 指定项目
```
使用方向键移动、空格勾选、`/` 搜索、Enter 安装,按 `q` 或 Esc
取消。已经安装到目标范围的 skill 默认勾选;取消勾选不会卸载已有 skill。
取消。普通 `skiff select` 只安装到项目,并在每一项旁只读显示各 Agent 的
全局安装状态;`skiff select -g` 只安装到全局。已经安装到目标范围的 skill
默认勾选;取消勾选不会卸载已有 skill,卸载请使用 `skiff remove`。如果全局
存在同名但指向其它来源的 skill,项目选择器会显示“全局同名冲突”。
项目模式会把成功选择的项目写入 `.skills.yaml`。非交互环境请使用
`skiff add <name>...`。使用 `-a` 限定 Agent 时,该范围会记录在对应的
skill 条目中,后续 `skiff sync` 不会扩散到其他 Agent。
@@ -171,8 +176,8 @@ skiff 不保存 token。可以使用 `company/code-review`,也可以使用
| 命令 | 说明 |
|------|------|
| `skiff create <name> [--idea TEXT] [--from-project PATH]` | 从模板创建草稿到 `~/.skills/.drafts/` |
| `skiff check <name>` | 校验草稿或正式 skill |
| `skiff create <name> [--idea TEXT] [--from-project PATH]` | 从模板创建含 `SKILL.md`、`README.md` 的草稿 |
| `skiff check <name>` | 校验草稿或正式 skill,包括人类使用说明 |
| `skiff finalize <name>` | 校验草稿并移动到正式 `skills/` |
| `skiff doctor [--target all] [--fix]` | 检查软链健康状态,`--fix` 自动修复 |
@@ -182,7 +187,7 @@ skiff 不保存 token。可以使用 `company/code-review`,也可以使用
```bash
skiff create my-skill --idea "描述要解决的重复问题" --from-project .
# 由 Agent 完善 ~/.skills/.drafts/my-skill/SKILL.md
# 由 Agent 完善草稿中的 SKILL.md 和 README.md
skiff check my-skill
skiff finalize my-skill
skiff publish skills/my-skill -m "add my-skill" --push
@@ -197,12 +202,12 @@ cd ~/code/my-app
skiff add declarative-openspec-loop -a cursor -y
```
### 安装外部 Git skill
### 添加 Catalog Source
```bash
skiff add my-ext https://github.com/org/repo --ref main
skiff catalog add my-ext https://github.com/org/repo --ref main
skiff fetch my-ext
skiff install-external my-ext
skiff add my-ext -g
```
## 源码结构
@@ -213,8 +218,8 @@ skiff/
├── __main__.py # python3 -m skiff 入口
├── cli.py # 命令定义与调度
├── paths.py # 路径常量与 Agent 目标
├── skills.py # 自研 skill 发现
├── registry.py # registry.yaml 读写
├── skills.py # builtin/catalog/custom 统一解析
├── catalog.py # catalog.yaml 读写与 Skill 发现
├── sources.py # custom source 配置、发现与 Git 管理
├── project.py # .skills.yaml 管理
├── symlinks.py # 软链创建/检查/修复
@@ -228,9 +233,9 @@ skiff/
| 变量 | 路径 | 说明 |
|------|------|------|
| `SKILLS_HOME` | `~/.skills` | skills 仓库(软链) |
| `SKILLS_DIR` | `~/.skills/skills/` | 自研 skill 目录 |
| `REGISTRY_FILE` | `~/.skills/registry.yaml` | 外部 skill 注册表 |
| `EXTERNALS_DIR` | `~/.local/share/skills/externals/` | 已 fetch 的外部仓库;新条目按 repo/ref 共享缓存 |
| `SKILLS_DIR` | `~/.skills/skills/` | builtin skill 目录 |
| `CATALOG_FILE` | `~/.skills/catalog.yaml` | 预置 Skill 来源目录 |
| `CATALOG_CACHE_DIR` | `~/.local/share/skills/externals/` | catalog checkout 兼容缓存;按 repo/ref 共享 |
| `CONFIG_FILE` | `~/.config/skiff/config.yaml` | custom source 配置 |
| `SOURCES_DIR` | `~/.local/share/skiff/sources/` | custom Git source 默认 checkout |
+1 -1
View File
@@ -1,3 +1,3 @@
"""skiff — Agent Skills 安装与管理 CLI。"""
__version__ = "0.5.0"
__version__ = "0.6.0"
+36 -38
View File
@@ -1,4 +1,4 @@
"""registry.yaml 读写。"""
"""预置 Skill catalog 的配置、发现与本地 checkout 路径。"""
from __future__ import annotations
@@ -7,64 +7,77 @@ from pathlib import Path
from typing import Any
from skiff import yaml_io
from skiff.paths import REGISTRY_FILE
from skiff.paths import CATALOG_FILE, LEGACY_REGISTRY_FILE
def load_registry(path: Path | None = None) -> dict[str, dict[str, Any]]:
path = path or REGISTRY_FILE
def load_catalog(path: Path | None = None) -> dict[str, dict[str, Any]]:
path = path or (
CATALOG_FILE if CATALOG_FILE.is_file() else LEGACY_REGISTRY_FILE
)
if not path.is_file():
return {}
data = yaml_io.safe_load(path.read_text(encoding="utf-8")) or {}
if not isinstance(data, dict):
raise SystemExit(f"registry 格式错误: {path}")
raise SystemExit(f"catalog 格式错误: {path}")
return {k: v for k, v in data.items() if isinstance(v, dict) and not k.startswith("#")}
def save_registry(data: dict[str, dict[str, Any]], path: Path | None = None) -> None:
path = path or REGISTRY_FILE
def save_catalog(data: dict[str, dict[str, Any]], path: Path | None = None) -> None:
path = path or CATALOG_FILE
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(yaml_io.safe_dump(data, allow_unicode=True, sort_keys=False), encoding="utf-8")
def external_repo_path(entry: dict[str, Any]) -> Path:
def catalog_repo(entry: dict[str, Any]) -> str:
"""Expand a local home-relative repo while leaving remote URLs unchanged."""
repo = str(entry.get("repo", ""))
return str(Path(repo).expanduser()) if repo.startswith("~") else repo
def catalog_repo_path(entry: dict[str, Any]) -> Path:
"""Return the shared checkout path for a repo/ref pair."""
from skiff.paths import EXTERNALS_DIR
from skiff.paths import CATALOG_CACHE_DIR
repo = str(entry.get("repo", ""))
ref = str(entry.get("ref", "main"))
digest = hashlib.sha256(f"{repo}\0{ref}".encode()).hexdigest()[:16]
return EXTERNALS_DIR / "_repos" / digest
return CATALOG_CACHE_DIR / "_repos" / digest
def external_checkout_path(name: str, entry: dict[str, Any]) -> Path:
"""Prefer an existing pre-shared-cache checkout for compatibility."""
from skiff.paths import EXTERNALS_DIR
def catalog_checkout_path(name: str, entry: dict[str, Any]) -> Path:
"""Use a local repo directly, otherwise return its external checkout."""
from skiff.paths import CATALOG_CACHE_DIR
legacy = EXTERNALS_DIR / name
return legacy if legacy.is_dir() else external_repo_path(entry)
configured_repo = str(entry.get("repo", ""))
local_repo = Path(catalog_repo(entry))
if configured_repo.startswith("~") and local_repo.is_dir():
return local_repo.resolve()
legacy = CATALOG_CACHE_DIR / name
return legacy if legacy.is_dir() else catalog_repo_path(entry)
def external_skill_path(name: str, entry: dict[str, Any] | None = None) -> Path:
entry = entry or load_registry().get(name, {})
def catalog_skill_path(name: str, entry: dict[str, Any] | None = None) -> Path:
entry = entry or load_catalog().get(name, {})
subpath = entry.get("path", ".") or "."
checkout = external_checkout_path(name, entry).resolve()
checkout = catalog_checkout_path(name, entry).resolve()
skill_path = (checkout / subpath).resolve()
try:
skill_path.relative_to(checkout)
except ValueError as exc:
raise SystemExit(
f"registry 条目 {name!r} 的 path 超出外部仓库: {subpath!r}"
f"catalog 条目 {name!r} 的 path 超出来源仓库: {subpath!r}"
) from exc
return skill_path
def discover_external_skills(
def discover_catalog_skills(
name: str,
entry: dict[str, Any] | None = None,
) -> dict[str, Path]:
"""Discover a single registry skill or a collection of sibling skills."""
entry = entry or load_registry().get(name, {})
root = external_skill_path(name, entry)
"""Discover a catalog provider containing one or more skills."""
entry = entry or load_catalog().get(name, {})
root = catalog_skill_path(name, entry)
if (root / "SKILL.md").is_file():
return {name: root}
if not root.is_dir():
@@ -90,18 +103,3 @@ def discover_external_skills(
continue
skills[item.name] = item
return skills
def external_collection_skill_path(
collection: str,
skill_name: str,
entry: dict[str, Any] | None = None,
) -> Path:
skills = discover_external_skills(collection, entry)
if skill_name not in skills:
available = ", ".join(skills) or "(无)"
raise SystemExit(
f"registry collection {collection!r} 中找不到 skill {skill_name!r}。"
f"可用: {available}"
)
return skills[skill_name]
+400 -262
View File
@@ -15,7 +15,7 @@ from skiff.gitops import publish as git_publish
from skiff.paths import (
ALL_TARGETS,
DRAFTS_DIR,
EXTERNALS_DIR,
CATALOG_CACHE_DIR,
CONFIG_FILE,
SKILLS_DIR,
SKILLS_HOME,
@@ -29,21 +29,22 @@ from skiff.project import (
load_manifest,
remove_skill_from_manifest,
resolve_manifest_skill,
save_manifest,
)
from skiff.registry import (
discover_external_skills,
external_checkout_path,
external_skill_path,
load_registry,
save_registry,
from skiff.catalog import (
discover_catalog_skills,
catalog_checkout_path,
catalog_skill_path,
load_catalog,
catalog_repo,
save_catalog,
)
from skiff.selector import SkillChoice, select_skills
from skiff.skills import (
list_custom_skills,
list_owned_skills,
owned_skill_path,
list_builtin_skills,
builtin_skill_path,
read_skill_meta,
normalize_source,
resolve_skill_source,
split_skill_spec,
skill_description,
@@ -51,6 +52,7 @@ from skiff.skills import (
validate_skill_name,
)
from skiff.sources import (
discover_source_skills,
fetch_source,
load_sources,
save_sources,
@@ -70,28 +72,31 @@ def _err(msg: str) -> None:
print(msg, file=sys.stderr)
def _warn_deprecated(old: str, new: str) -> None:
_err(f"警告: `{old}` 已弃用,请改用 `{new}`")
def _project_root(explicit: str | None = None) -> Path:
if explicit:
return Path(explicit).resolve()
return find_repo_root() or Path.cwd()
def _render_template(source: Path, destination: Path, values: dict[str, str]) -> None:
content = source.read_text(encoding="utf-8")
for placeholder, value in values.items():
content = content.replace(placeholder, value)
destination.write_text(content, encoding="utf-8")
def _collect_skill_names(positional: list[str] | None, flagged: list[str] | None) -> list[str]:
names = list(positional or [])
if flagged:
names.extend(flagged)
if "*" in names:
return list_owned_skills()
return list_builtin_skills()
return names
def _ensure_source_fetched(name: str, source: str | None = None) -> None:
name, source = split_skill_spec(name, source)
registry = load_registry()
catalog = load_catalog()
sources = load_sources()
if source in sources:
root = source_skills_root(source, sources[source])
@@ -100,68 +105,68 @@ def _ensure_source_fetched(name: str, source: str | None = None) -> None:
fetch_source(source, sources[source])
return
registry_name = (
catalog_name = (
source.split(":", 1)[1]
if source and source.startswith("registry:")
if source and source.startswith("catalog:")
else source
if source in registry
if source in catalog
else name
if name in registry
if name in catalog
else None
)
if registry_name and (
source in (None, "registry", registry_name)
or source == f"registry:{registry_name}"
if catalog_name and (
source in (None, "catalog", catalog_name)
or source == f"catalog:{catalog_name}"
):
_ensure_registry_fetched(registry_name, registry[registry_name])
_ensure_catalog_fetched(catalog_name, catalog[catalog_name])
return
if source not in (None, "registry"):
if source not in (None, "catalog"):
return
if name not in registry:
if name not in catalog:
return
_ensure_registry_fetched(name, registry[name])
_ensure_catalog_fetched(name, catalog[name])
def _ensure_registry_fetched(name: str, entry: dict[str, object]) -> None:
path = external_skill_path(name, entry)
if (path / "SKILL.md").is_file() or discover_external_skills(name, entry):
def _ensure_catalog_fetched(name: str, entry: dict[str, object]) -> None:
path = catalog_skill_path(name, entry)
if (path / "SKILL.md").is_file() or discover_catalog_skills(name, entry):
return
repo = str(entry["repo"])
repo = catalog_repo(entry)
ref = entry.get("ref", "main")
dest = external_checkout_path(name, entry)
dest = catalog_checkout_path(name, entry)
dest.parent.mkdir(parents=True, exist_ok=True)
if dest.exists():
raise SystemExit(
f"外部仓库已存在但 skill 路径无效: {external_skill_path(name, entry)}"
f"外部仓库已存在但 skill 路径无效: {catalog_skill_path(name, entry)}"
)
_print(f"拉取外部 skill: {name}")
subprocess.run(
["git", "clone", "--branch", ref, "--", repo, str(dest)],
["git", "clone", "--depth", "1", "--branch", ref, "--", repo, str(dest)],
check=True,
)
if not discover_external_skills(name, entry):
if not discover_catalog_skills(name, entry):
raise SystemExit(
f"registry 条目 {name!r} 的 path 中没有可安装的 SKILL.md: {path}"
f"catalog 条目 {name!r} 的 path 中没有可安装的 SKILL.md: {path}"
)
def _registry_skill_names(
def _catalog_skill_names(
name: str,
entry: dict[str, object] | None = None,
) -> list[str]:
entry = entry or load_registry().get(name)
entry = entry or load_catalog().get(name)
if not entry:
raise SystemExit(f"registry 中不存在: {name}")
_ensure_registry_fetched(name, entry)
names = list(discover_external_skills(name, entry))
raise SystemExit(f"catalog 中不存在: {name}")
_ensure_catalog_fetched(name, entry)
names = list(discover_catalog_skills(name, entry))
for skill_name in names:
validate_skill_name(skill_name)
if not names:
raise SystemExit(f"registry 条目 {name!r} 中没有可安装的 skill")
raise SystemExit(f"catalog 条目 {name!r} 中没有可安装的 skill")
return names
@@ -170,34 +175,39 @@ def _expand_install_request(
explicit_source: str | None = None,
) -> list[tuple[str, str | None]]:
name, source = split_skill_spec(spec, explicit_source)
registry = load_registry()
if source in load_sources():
catalog = load_catalog()
sources = load_sources()
if source in sources:
return [(name, source)]
if source == "registry" and name in registry:
available = _registry_skill_names(name, registry[name])
root = external_skill_path(name, registry[name])
if source == "catalog" and name in catalog:
available = _catalog_skill_names(name, catalog[name])
root = catalog_skill_path(name, catalog[name])
if (root / "SKILL.md").is_file():
return [(name, "registry")]
return [(skill_name, f"registry:{name}") for skill_name in available]
if source in registry:
available = _registry_skill_names(source, registry[source])
return [(name, "catalog")]
return [(skill_name, f"catalog:{name}") for skill_name in available]
if source in catalog:
available = _catalog_skill_names(source, catalog[source])
if name not in available:
raise SystemExit(
f"registry collection {source!r} 中找不到 skill {name!r}"
f"catalog collection {source!r} 中找不到 skill {name!r}"
)
return [(name, f"registry:{source}")]
if source is None and name in registry:
available = _registry_skill_names(name, registry[name])
root = external_skill_path(name, registry[name])
return [(name, f"catalog:{source}")]
if source is None and name in catalog:
available = _catalog_skill_names(name, catalog[name])
root = catalog_skill_path(name, catalog[name])
if (root / "SKILL.md").is_file():
return [(name, "registry")]
return [(skill_name, f"registry:{name}") for skill_name in available]
return [(name, f"catalog:{name}")]
return [(skill_name, f"catalog:{name}") for skill_name in available]
if source is None and name in sources:
_ensure_source_fetched(name, name)
available = discover_source_skills(name, sources[name])
if not available:
raise SystemExit(f"custom source {name!r} 中没有可安装的 skill")
return [(skill_name, name) for skill_name in available]
return [(name, source)]
def _manifest_source_details(resolved_source: str) -> tuple[str, dict[str, object]]:
if resolved_source.startswith("registry:"):
return "registry", {"registry": resolved_source.split(":", 1)[1]}
return resolved_source, {}
@@ -277,6 +287,29 @@ def _is_fully_installed(
)
def _global_installation_note(
name: str,
expected: Path,
targets: list[str],
) -> str:
installed: list[str] = []
conflicts: list[str] = []
for target in targets:
link = agent_skill_dir(target) / name
status = check_link(link, expected)
if status.ok:
installed.append(target)
elif link.exists() or link.is_symlink():
conflicts.append(target)
parts: list[str] = []
if installed:
parts.append(f"全局: {','.join(installed)}")
if conflicts:
parts.append(f"全局同名冲突: {','.join(conflicts)}")
return ";".join(parts)
def _remove_skill(
name: str,
targets: list[str],
@@ -298,27 +331,37 @@ def _remove_skill(
def cmd_list(args: argparse.Namespace) -> None:
ensure_skills_home()
owned = list_owned_skills()
registry = load_registry()
source_filter = normalize_source(args.source)
builtin = list_builtin_skills()
catalog = load_catalog()
custom = (
{}
if args.source in ("owned", "registry")
else list_custom_skills(args.source)
if source_filter in ("builtin", "catalog")
or (source_filter and source_filter.startswith("catalog:"))
else list_custom_skills(source_filter)
)
if args.source in (None, "owned"):
_print("自研 (owned):")
for name in owned:
if source_filter in (None, "builtin"):
_print("内置 (builtin):")
for name in builtin:
_print(f" {name}")
if args.source in (None, "registry"):
_print("\n外部 (registry):")
if not registry:
if source_filter in (None, "catalog"):
_print("\n目录 (catalog):")
if not catalog:
_print(" (无)")
else:
for name, entry in registry.items():
for name, entry in catalog.items():
repo = entry.get("repo", "?")
_print(f" {name} ({repo})")
elif source_filter and source_filter.startswith("catalog:"):
provider = source_filter.split(":", 1)[1]
if provider not in catalog:
raise SystemExit(f"catalog 中不存在: {provider}")
_ensure_catalog_fetched(provider, catalog[provider])
_print(f"目录 (catalog:{provider}):")
for name in discover_catalog_skills(provider, catalog[provider]):
_print(f" {name}")
for source, names in custom.items():
_print(f"\n自定义 ({source}):")
@@ -332,9 +375,16 @@ def cmd_bootstrap(args: argparse.Namespace) -> None:
del args
ensure_skills_home()
project_skill = "skiff"
owned_skill_path(project_skill)
builtin_skill_path(project_skill)
_install_skill(project_skill, list(ALL_TARGETS), project_root=None)
_print("已安装项目 skill 到所有 agent")
_print("已全局安装 builtin skiff skill 到所有 agent")
def cmd_update(args: argparse.Namespace) -> None:
del args
ensure_skills_home()
_print(f"更新 skiff: {SKILLS_HOME}")
subprocess.run(["git", "-C", str(SKILLS_HOME), "pull"], check=True)
def _installed_links(
@@ -355,20 +405,20 @@ def _installed_links(
def cmd_status(args: argparse.Namespace) -> None:
ensure_skills_home()
targets = resolve_agent_args(flatten_agent_args(args.agents))
owned = list_owned_skills()
registry = load_registry()
builtin = list_builtin_skills()
catalog = load_catalog()
custom = list_custom_skills()
entries = [("owned", name) for name in owned]
unfetched_registry: list[str] = []
for package, entry in registry.items():
discovered = discover_external_skills(package, entry)
entries = [("builtin", name) for name in builtin]
unfetched_catalog: list[str] = []
for package, entry in catalog.items():
discovered = discover_catalog_skills(package, entry)
if not discovered:
unfetched_registry.append(package)
elif (external_skill_path(package, entry) / "SKILL.md").is_file():
entries.append(("registry", package))
unfetched_catalog.append(package)
elif (catalog_skill_path(package, entry) / "SKILL.md").is_file():
entries.append((f"catalog:{package}", package))
else:
entries.extend(
(f"registry:{package}", skill_name)
(f"catalog:{package}", skill_name)
for skill_name in discovered
)
entries.extend((source, name) for source, names in custom.items() for name in names)
@@ -376,8 +426,8 @@ def cmd_status(args: argparse.Namespace) -> None:
_print(f"skills 仓库: {SKILLS_HOME.resolve()}")
_print(f"agents: {', '.join(targets)}\n")
for package in unfetched_registry:
_print(f"[registry] {package}\n (未 fetch)\n")
for package in unfetched_catalog:
_print(f"[catalog] {package}\n (未 fetch)\n")
for source, name in entries:
_print(f"[{source}] {name}")
@@ -400,13 +450,13 @@ def cmd_status(args: argparse.Namespace) -> None:
def _print_available_skills() -> None:
ensure_skills_home()
owned = list_owned_skills()
if not owned:
builtin = list_builtin_skills()
if not builtin:
_print("~/.skills/skills/ 中没有自研 skill")
return
_print(f"来源: {SKILLS_DIR}\n")
for name in owned:
for name in builtin:
desc = skill_description(name)
_print(f" {name}")
if desc:
@@ -428,15 +478,16 @@ def cmd_add(args: argparse.Namespace) -> None:
if args.all:
if args.source:
if args.source == "owned":
names = list_owned_skills()
elif args.source == "registry":
names = list(load_registry())
source = normalize_source(args.source)
if source == "builtin":
names = list_builtin_skills()
elif source == "catalog":
names = list(load_catalog())
else:
_ensure_source_fetched("", args.source)
names = list_custom_skills(args.source)[args.source]
_ensure_source_fetched("", source)
names = list_custom_skills(source)[source]
else:
names = list_owned_skills()
names = list_builtin_skills()
targets = resolve_agent_args(["*"])
else:
names = _collect_skill_names(args.skills, args.skills_flag)
@@ -511,70 +562,145 @@ def cmd_select(args: argparse.Namespace) -> None:
targets = resolve_agent_args(flatten_agent_args(args.agents))
project_root = None if args.global_scope else _project_root(args.project)
registry = load_registry()
owned_names = list_owned_skills()
for name in [*owned_names, *registry]:
catalog = load_catalog()
builtin_names = list_builtin_skills()
for name in [*builtin_names, *catalog]:
validate_skill_name(name)
choices = [
SkillChoice(
def make_choice(
*,
name: str,
installed_name: str,
expected: Path,
kind: str,
description: str,
indent: int = 0,
) -> SkillChoice:
return SkillChoice(
name=name,
kind="owned",
description=skill_description(name) or "",
kind=kind,
description=description,
installed=_is_fully_installed(
name,
SKILLS_DIR / name,
installed_name,
expected,
project_root,
targets,
),
readonly_status=(
_global_installation_note(installed_name, expected, targets)
if project_root is not None
else ""
),
indent=indent,
)
for name in owned_names
choices = [
make_choice(
name=name,
installed_name=name,
expected=SKILLS_DIR / name,
kind="builtin",
description=skill_description(name) or "",
)
for name in builtin_names
]
choice_requests: dict[str, tuple[str, str]] = {
name: (name, "owned") for name in owned_names
name: (name, "builtin") for name in builtin_names
}
for package, entry in registry.items():
skill_names = _registry_skill_names(package, entry)
root = external_skill_path(package, entry)
if (root / "SKILL.md").is_file():
if package in choice_requests:
_err(f"警告: registry 条目与 owned skill 同名,已忽略 external: {package}")
continue
choices.append(
SkillChoice(
name=package,
kind="external",
description=str(entry.get("description", "")),
installed=_is_fully_installed(
package,
root,
project_root,
targets,
),
def add_provider_choices(
provider: str,
*,
registration: str,
discovered: dict[str, Path],
description: str,
) -> None:
source = provider if registration == "custom" else f"catalog:{provider}"
if len(discovered) == 1 and provider in discovered:
expected = discovered[provider]
if provider in choice_requests:
_err(
f"警告: {registration} source 与已有 skill 同名,"
f"已忽略: {provider}"
)
)
choice_requests[package] = (package, "registry")
continue
for skill_name in skill_names:
choice_name = f"{package}/{skill_name}"
description = read_skill_meta(root / skill_name).get("description", "")
return
choices.append(
SkillChoice(
name=choice_name,
kind=f"external:{package}",
make_choice(
name=provider,
installed_name=provider,
expected=expected,
kind=f"{registration}:{provider}",
description=description,
installed=_is_fully_installed(
skill_name,
root / skill_name,
project_root,
targets,
),
)
)
choice_requests[choice_name] = (skill_name, f"registry:{package}")
choice_requests[provider] = (provider, source)
return
child_names: list[str] = []
first_child = len(choices)
for skill_name, expected in discovered.items():
if (
skill_name in builtin_names
and expected.resolve() == (SKILLS_DIR / skill_name).resolve()
):
continue
choice_name = f"{provider}/{skill_name}"
choices.append(
make_choice(
name=choice_name,
installed_name=skill_name,
expected=expected,
kind=f"{registration}:{provider}",
description=read_skill_meta(expected).get("description", ""),
indent=1,
)
)
choice_requests[choice_name] = (skill_name, source)
child_names.append(choice_name)
if child_names:
choices.insert(
first_child,
SkillChoice(
name=provider,
kind=f"{registration} source",
description=description,
children=tuple(child_names),
),
)
for package, entry in catalog.items():
skill_names = _catalog_skill_names(package, entry)
root = catalog_skill_path(package, entry)
discovered = (
{package: root}
if (root / "SKILL.md").is_file()
else {skill_name: root / skill_name for skill_name in skill_names}
)
add_provider_choices(
package,
registration="catalog",
discovered=discovered,
description=str(entry.get("description", "")),
)
custom_sources = load_sources()
for provider, entry in custom_sources.items():
validate_skill_name(provider)
_ensure_source_fetched("", provider)
add_provider_choices(
provider,
registration="custom",
discovered=discover_source_skills(provider, entry),
description=str(entry.get("description", "")),
)
try:
selected = select_skills(choices)
scope_label = (
"全局"
if project_root is None
else f"项目 {project_root}(全局状态只读)"
)
selected = select_skills(choices, scope_label=scope_label)
except (RuntimeError, OSError) as exc:
raise SystemExit(str(exc)) from exc
if selected is None:
@@ -616,31 +742,13 @@ def cmd_select(args: argparse.Namespace) -> None:
entry_targets = targets if args.agents else None
for name in sorted(successful):
skill_name, source = choice_requests[name]
if source == "owned":
add_skill_to_manifest(
manifest_path,
skill_name,
source="owned",
extra={"targets": entry_targets} if entry_targets else None,
)
else:
package = (
skill_name
if source == "registry"
else source.split(":", 1)[1]
)
entry = registry[package]
extra: dict[str, object] = {"ref": entry.get("ref", "main")}
if source != "registry":
extra["registry"] = package
if entry_targets:
extra["targets"] = entry_targets
add_skill_to_manifest(
manifest_path,
skill_name,
source="registry",
extra=extra,
)
extra = {"targets": entry_targets} if entry_targets else None
add_skill_to_manifest(
manifest_path,
skill_name,
source=source,
extra=extra,
)
installed_count = len(names) - len(failures)
if not names:
@@ -664,24 +772,30 @@ def cmd_remove(args: argparse.Namespace) -> None:
if not names:
raise SystemExit("请指定 skill 名称,或使用 skiff remove --all")
registry = load_registry()
catalog = load_catalog()
sources = load_sources()
expanded: list[str] = []
for spec in names:
name, source = split_skill_spec(spec)
if source in registry and source not in load_sources():
if name not in discover_external_skills(source, registry[source]):
if source and source.startswith("catalog:"):
provider = source.split(":", 1)[1]
if provider not in catalog:
raise SystemExit(f"catalog 中不存在: {provider}")
if name not in discover_catalog_skills(provider, catalog[provider]):
raise SystemExit(
f"registry collection {source!r} 中找不到 skill {name!r}"
f"catalog source {provider!r} 中找不到 skill {name!r}"
)
expanded.append(name)
elif source is None and name in registry:
discovered = discover_external_skills(name, registry[name])
root = external_skill_path(name, registry[name])
elif source is None and name in catalog:
discovered = discover_catalog_skills(name, catalog[name])
root = catalog_skill_path(name, catalog[name])
expanded.extend(
[name]
if (root / "SKILL.md").is_file()
else list(discovered)
)
elif source is None and name in sources:
expanded.extend(discover_source_skills(name, sources[name]))
else:
expanded.append(name)
@@ -706,34 +820,34 @@ def cmd_publish(args: argparse.Namespace) -> None:
)
def cmd_registry_add(args: argparse.Namespace) -> None:
def cmd_catalog_add(args: argparse.Namespace) -> None:
ensure_skills_home()
validate_skill_name(args.name)
registry = load_registry()
if args.name in registry:
raise SystemExit(f"registry 中已存在: {args.name}")
catalog = load_catalog()
if args.name in catalog:
raise SystemExit(f"catalog 中已存在: {args.name}")
registry[args.name] = {
catalog[args.name] = {
"repo": args.repo,
"ref": args.ref,
"path": args.path,
}
save_registry(registry)
_print(f"已添加 registry 条目: {args.name}")
save_catalog(catalog)
_print(f"已添加 catalog 条目: {args.name}")
def cmd_fetch(args: argparse.Namespace) -> None:
ensure_skills_home()
registry = load_registry()
if args.name not in registry:
raise SystemExit(f"registry 中不存在: {args.name}")
catalog = load_catalog()
if args.name not in catalog:
raise SystemExit(f"catalog 中不存在: {args.name}")
entry = registry[args.name]
repo = entry["repo"]
entry = catalog[args.name]
repo = catalog_repo(entry)
ref = entry.get("ref", "main")
dest = external_checkout_path(args.name, entry)
dest = catalog_checkout_path(args.name, entry)
EXTERNALS_DIR.mkdir(parents=True, exist_ok=True)
CATALOG_CACHE_DIR.mkdir(parents=True, exist_ok=True)
if dest.exists():
_print(f"更新: {dest}")
@@ -743,7 +857,7 @@ def cmd_fetch(args: argparse.Namespace) -> None:
else:
_print(f"克隆: {repo} -> {dest}")
subprocess.run(
["git", "clone", "--branch", ref, "--", repo, str(dest)],
["git", "clone", "--depth", "1", "--branch", ref, "--", repo, str(dest)],
check=True,
)
@@ -836,40 +950,6 @@ def cmd_source_remove(args: argparse.Namespace) -> None:
_print(f"已删除 checkout(不可恢复): {checkout}")
def cmd_enable(args: argparse.Namespace) -> None:
_warn_deprecated("skiff enable", "skiff add <name>")
ensure_skills_home()
validate_skill_name(args.name)
root = _project_root(args.project)
manifest_path = root / ".skills.yaml"
name, source = split_skill_spec(args.name)
_ensure_source_fetched(name, source)
_, resolved_source = resolve_skill_source(name, source=source)
add_skill_to_manifest(manifest_path, name, source=resolved_source)
targets = resolve_agent_args(flatten_agent_args(args.agents))
_, data = load_manifest(manifest_path)
manifest_targets = data.get("targets")
if manifest_targets:
targets = [t for t in targets if t in manifest_targets]
_install_skill(name, targets, project_root=root, source=resolved_source)
_print(f"已启用项目 skill: {resolved_source}/{name} @ {root}")
def cmd_disable(args: argparse.Namespace) -> None:
_warn_deprecated("skiff disable", "skiff remove <name>")
root = _project_root(args.project)
manifest_path = root / ".skills.yaml"
if not remove_skill_from_manifest(manifest_path, args.name):
_print(f"manifest 中不存在: {args.name}")
return
targets = resolve_agent_args(flatten_agent_args(args.agents))
_remove_skill(args.name, targets, project_root=root)
def cmd_sync(args: argparse.Namespace) -> None:
ensure_skills_home()
root = _project_root(args.project)
@@ -886,8 +966,6 @@ def cmd_sync(args: argparse.Namespace) -> None:
for entry in iter_manifest_skills(data):
name = entry["name"]
source = entry.get("source")
if source == "registry" and entry.get("registry"):
source = f"registry:{entry['registry']}"
_ensure_source_fetched(name, source)
skill_path, _ = resolve_manifest_skill(entry)
skill_targets = targets
@@ -918,6 +996,16 @@ def cmd_create(args: argparse.Namespace) -> None:
flags=re.MULTILINE,
)
skill_md.write_text(content, encoding="utf-8")
readme = dst / "README.md"
if readme.is_file():
readme_content = re.sub(
r"^#\s+skill-name\s*$",
f"# {args.name}",
readme.read_text(encoding="utf-8"),
count=1,
flags=re.MULTILINE,
)
readme.write_text(readme_content, encoding="utf-8")
brief = {
"name": args.name,
"idea": args.idea or "",
@@ -931,20 +1019,20 @@ def cmd_create(args: argparse.Namespace) -> None:
_print(f"完成后运行: skiff check {args.name} && skiff finalize {args.name}")
def _draft_or_owned_path(name: str) -> tuple[Path, str]:
def _draft_or_builtin_path(name: str) -> tuple[Path, str]:
draft = DRAFTS_DIR / name
if draft.is_dir():
return draft, "草稿"
owned = SKILLS_DIR / name
if owned.is_dir():
return owned, "正式 skill"
builtin = SKILLS_DIR / name
if builtin.is_dir():
return builtin, "正式 skill"
raise SystemExit(f"找不到草稿或正式 skill: {name}")
def cmd_check(args: argparse.Namespace) -> None:
ensure_skills_home()
validate_skill_name(args.name)
path, kind = _draft_or_owned_path(args.name)
path, kind = _draft_or_builtin_path(args.name)
issues = validate_skill_dir(path, args.name)
if kind == "草稿":
issues = [issue for issue in issues if "草稿文件: brief.yaml" not in issue]
@@ -999,8 +1087,8 @@ def cmd_doctor(args: argparse.Namespace) -> None:
_err("✗ ~/.skills 未正确配置")
issues += 1
for name in list_owned_skills():
for target, link, expected in _installed_links(name, targets, source="owned"):
for name in list_builtin_skills():
for target, link, expected in _installed_links(name, targets, source="builtin"):
status = check_link(link, expected)
if status.ok:
continue
@@ -1013,9 +1101,9 @@ def cmd_doctor(args: argparse.Namespace) -> None:
except Exception as exc: # noqa: BLE001
_err(f" 修复失败: {exc}")
registry = load_registry()
for name in registry:
ext = external_skill_path(name, registry[name])
catalog = load_catalog()
for name in catalog:
ext = catalog_skill_path(name, catalog[name])
if not ext.exists():
_err(f"✗ 外部 skill 未 fetch: {name}")
issues += 1
@@ -1039,6 +1127,60 @@ def cmd_doctor(args: argparse.Namespace) -> None:
sys.exit(1)
def cmd_init(args: argparse.Namespace) -> None:
"""使用 builtin skill 自带的模板初始化目标项目状态。"""
ensure_skills_home()
skill_source = SKILLS_DIR / args.name
if not (skill_source / "SKILL.md").is_file():
raise SystemExit(f"builtin skill 不存在: {args.name}")
project = _project_root(args.project)
if not project.is_dir():
raise SystemExit(f"项目目录不存在: {project}")
destination = project / "docs" / args.name
project_file = destination / "project.md"
tasks_file = destination / "tasks.yaml"
managed_targets = (project_file, tasks_file)
existing = [path for path in managed_targets if path.exists() or path.is_symlink()]
if existing:
paths = ", ".join(str(path.relative_to(project)) for path in existing)
raise SystemExit(f"拒绝覆盖已有路径: {paths}")
project_template = skill_source / "templates" / "project.template.md"
tasks_template = skill_source / "templates" / "tasks.template.yaml"
missing = [path for path in (project_template, tasks_template) if not path.is_file()]
if missing:
paths = ", ".join(str(path.relative_to(SKILLS_HOME)) for path in missing)
raise SystemExit(f"skill 缺少初始化模板: {paths}")
destination.mkdir(parents=True, exist_ok=True)
version_file = skill_source / "VERSION"
ack_version = version_file.read_text(encoding="utf-8").strip() if version_file.is_file() else "unknown"
now = datetime.now().astimezone().isoformat(timespec="seconds")
values = {
"<project_name>": project.name,
"<repo_path>": str(project),
"<dev_worktree>": str(project),
"<overlay_file_path>": f"docs/{args.name}/project.md",
"<ack_version>": ack_version,
"<接入时的 ack skill 版本>": ack_version,
"<YYYY-MM-DDTHH:mm:ss+TZ>": now,
}
_render_template(project_template, project_file, values)
_render_template(tasks_template, tasks_file, values)
validator = skill_source / "scripts" / "validate_tasks.py"
if validator.is_file():
subprocess.run([sys.executable, str(validator), str(tasks_file)], check=True)
_print(f"✓ skill 项目状态初始化完成: {args.name}")
_print(f" 项目: {project}")
_print(f" 覆盖层: {project_file}")
_print(f" 任务板: {tasks_file}")
_print("下一步: 填写 project.md 中的项目命令、路径权限和 Base URL")
def _add_common_flags(parser: argparse.ArgumentParser) -> None:
parser.add_argument(
"-a",
@@ -1075,8 +1217,11 @@ def build_parser() -> argparse.ArgumentParser:
)
p_bootstrap.set_defaults(func=cmd_bootstrap)
p_update = sub.add_parser("update", help="通过 git pull 更新 skiff 自身")
p_update.set_defaults(func=cmd_update)
p_list = sub.add_parser("list", help="列出所有 source 中的 skill")
p_list.add_argument("--source", help="只列出指定来源(owned、registry 或 custom source)")
p_list.add_argument("--source", help="只列出指定来源(builtin、catalog 或 custom source)")
p_list.set_defaults(func=cmd_list)
p_status = sub.add_parser("status", help="安装状态总览")
@@ -1085,13 +1230,13 @@ def build_parser() -> argparse.ArgumentParser:
p_add = sub.add_parser(
"add",
help="安装 skill 到 agent(自研或 registry)",
description="安装 ~/.skills 中的自研 skill,或 registry 中的外部 skill",
help="安装 builtin、catalog 或 custom source 中的 skill",
description="安装 builtin、catalog 或 custom source 中的 skill",
)
p_add.add_argument("skills", nargs="*", metavar="skill", help="skill 名称(可多个)")
p_add.add_argument("-s", "--skill", dest="skills_flag", action="append", metavar="SKILL")
p_add.add_argument("--list", dest="list_available", action="store_true", help="列出可用自研 skill,不安装")
p_add.add_argument("--all", action="store_true", help="安装全部自研 skill 到全部 agent")
p_add.add_argument("--list", dest="list_available", action="store_true", help="列出可用 builtin skill,不安装")
p_add.add_argument("--all", action="store_true", help="安装指定来源的全部 skill 到全部 agent")
p_add.add_argument("--source", help="指定 skill 来源(也可使用 source/name)")
_add_common_flags(p_add)
p_add.set_defaults(func=cmd_add)
@@ -1124,20 +1269,20 @@ def build_parser() -> argparse.ArgumentParser:
p_publish.add_argument("--no-commit", action="store_true", help="只 git add,不 commit")
p_publish.set_defaults(func=cmd_publish)
p_registry = sub.add_parser("registry", help="管理 registry.yaml 中的外部 skill")
registry_sub = p_registry.add_subparsers(dest="registry_command", required=True)
p_reg_add = registry_sub.add_parser("add", help="注册外部 Git skill")
p_reg_add.add_argument("name", help="registry 名称")
p_reg_add.add_argument("repo", help="Git 仓库 URL")
p_reg_add.add_argument("--ref", default="main", help="分支或 tag(默认 main)")
p_reg_add.add_argument("--path", default=".", help="仓库内子路径(默认 .)")
p_reg_add.set_defaults(func=cmd_registry_add)
p_catalog = sub.add_parser("catalog", help="管理 catalog.yaml 中的预置来源")
catalog_sub = p_catalog.add_subparsers(dest="catalog_command", required=True)
p_catalog_add = catalog_sub.add_parser("add", help="添加预置 Git source")
p_catalog_add.add_argument("name", help="catalog source 名称")
p_catalog_add.add_argument("repo", help="Git 仓库 URL")
p_catalog_add.add_argument("--ref", default="main", help="分支或 tag(默认 main)")
p_catalog_add.add_argument("--path", default=".", help="仓库内子路径(默认 .)")
p_catalog_add.set_defaults(func=cmd_catalog_add)
p_fetch = sub.add_parser("fetch", help="拉取/更新 registry 中的外部 skill")
p_fetch.add_argument("name", help="registry 名称")
p_fetch = sub.add_parser("fetch", help="拉取/更新 catalog source")
p_fetch.add_argument("name", help="catalog 名称")
p_fetch.set_defaults(func=cmd_fetch)
p_source = sub.add_parser("source", help="管理包含多个 skills 的自定义仓库")
p_source = sub.add_parser("source", help="管理自定义 Skill source")
source_sub = p_source.add_subparsers(dest="source_command", required=True)
p_source_add = source_sub.add_parser("add", help="注册 Git 或本地 skill source")
@@ -1171,18 +1316,6 @@ def build_parser() -> argparse.ArgumentParser:
)
p_source_remove.set_defaults(func=cmd_source_remove)
p_enable = sub.add_parser("enable", help=argparse.SUPPRESS)
p_enable.add_argument("name")
p_enable.add_argument("-a", "--agent", dest="agents", nargs="+", action="append")
p_enable.add_argument("--project")
p_enable.set_defaults(func=cmd_enable)
p_disable = sub.add_parser("disable", help=argparse.SUPPRESS)
p_disable.add_argument("name")
p_disable.add_argument("-a", "--agent", dest="agents", nargs="+", action="append")
p_disable.add_argument("--project")
p_disable.set_defaults(func=cmd_disable)
p_sync = sub.add_parser("sync", help="按 .skills.yaml 重建项目软链")
p_sync.add_argument("-a", "--agent", dest="agents", nargs="+", action="append")
p_sync.add_argument("--project")
@@ -1202,6 +1335,11 @@ def build_parser() -> argparse.ArgumentParser:
p_finalize.add_argument("name", help="skill 名称")
p_finalize.set_defaults(func=cmd_finalize)
p_init = sub.add_parser("init", help="使用 skill 模板初始化项目状态")
p_init.add_argument("name", help="skill 名称")
p_init.add_argument("--project", help="项目根目录(默认自动检测或当前目录)")
p_init.set_defaults(func=cmd_init)
p_doctor = sub.add_parser("doctor", help="软链健康检查")
p_doctor.add_argument("-a", "--agent", dest="agents", nargs="+", action="append")
p_doctor.add_argument("--fix", action="store_true", help="自动修复可修复的软链")
+3 -2
View File
@@ -9,8 +9,9 @@ SKILLS_HOME = HOME / ".skills"
SKILLS_DIR = SKILLS_HOME / "skills"
TEMPLATE_DIR = SKILLS_DIR / "_template"
DRAFTS_DIR = SKILLS_HOME / ".drafts"
REGISTRY_FILE = SKILLS_HOME / "registry.yaml"
EXTERNALS_DIR = HOME / ".local" / "share" / "skills" / "externals"
CATALOG_FILE = SKILLS_HOME / "catalog.yaml"
LEGACY_REGISTRY_FILE = SKILLS_HOME / "registry.yaml"
CATALOG_CACHE_DIR = HOME / ".local" / "share" / "skills" / "externals"
CONFIG_FILE = HOME / ".config" / "skiff" / "config.yaml"
SOURCES_DIR = HOME / ".local" / "share" / "skiff" / "sources"
PROJECT_MANIFEST = ".skills.yaml"
+9 -9
View File
@@ -7,7 +7,7 @@ from typing import Any
from skiff import yaml_io
from skiff.paths import PROJECT_MANIFEST
from skiff.skills import resolve_skill_source
from skiff.skills import normalize_source, resolve_skill_source
def load_manifest(path: Path | None = None) -> tuple[Path, dict[str, Any]]:
@@ -31,11 +31,13 @@ def save_manifest(path: Path, data: dict[str, Any]) -> None:
def normalize_skill_entry(entry: str | dict[str, Any]) -> dict[str, Any]:
if isinstance(entry, str):
return {"name": entry, "source": "owned"}
return {"name": entry, "source": "builtin"}
name = entry.get("name")
if not name:
raise SystemExit(f".skills.yaml 条目缺少 name: {entry}")
source = entry.get("source", "owned")
source = normalize_source(str(entry.get("source", "builtin")))
if source == "catalog" and entry.get("registry"):
source = f"catalog:{entry['registry']}"
return {"name": name, "source": source, **{k: v for k, v in entry.items() if k not in ("name", "source")}}
@@ -44,8 +46,6 @@ def manifest_skill_names(data: dict[str, Any]) -> list[str]:
def _entry_to_yaml(entry: dict[str, Any]) -> str | dict[str, Any]:
if entry.get("source", "owned") == "owned" and set(entry.keys()) <= {"name", "source"}:
return entry["name"]
return entry
@@ -53,7 +53,7 @@ def add_skill_to_manifest(
manifest_path: Path,
name: str,
*,
source: str = "owned",
source: str = "builtin",
extra: dict[str, Any] | None = None,
) -> None:
path = manifest_path
@@ -100,7 +100,7 @@ def iter_manifest_skills(data: dict[str, Any]) -> list[dict[str, Any]]:
def resolve_manifest_skill(entry: dict[str, Any]) -> tuple[Path, str]:
name = entry["name"]
source = entry.get("source", "owned")
if source == "registry" and entry.get("registry"):
source = f"registry:{entry['registry']}"
source = normalize_source(entry.get("source", "builtin"))
if source == "catalog" and entry.get("registry"):
source = f"catalog:{entry['registry']}"
return resolve_skill_source(name, source=source)
+136 -21
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
import unicodedata
from dataclasses import dataclass
from typing import Callable, Iterable
@@ -12,6 +13,38 @@ class SkillChoice:
kind: str
description: str = ""
installed: bool = False
readonly_status: str = ""
children: tuple[str, ...] = ()
indent: int = 0
def fit_to_width(text: str, width: int) -> str:
"""Trim text to terminal display width without splitting wide characters."""
if width <= 0:
return ""
result: list[str] = []
used = 0
for char in text:
if unicodedata.combining(char):
char_width = 0
else:
char_width = 2 if unicodedata.east_asian_width(char) in {"W", "F"} else 1
if used + char_width > width:
break
result.append(char)
used += char_width
return "".join(result)
def display_width(text: str) -> int:
return sum(
0
if unicodedata.combining(char)
else 2
if unicodedata.east_asian_width(char) in {"W", "F"}
else 1
for char in text
)
def filter_choices(choices: Iterable[SkillChoice], query: str) -> list[SkillChoice]:
@@ -24,12 +57,14 @@ def filter_choices(choices: Iterable[SkillChoice], query: str) -> list[SkillChoi
if needle in choice.name.casefold()
or needle in choice.kind.casefold()
or needle in choice.description.casefold()
or needle in choice.readonly_status.casefold()
]
def select_skills(
choices: list[SkillChoice],
*,
scope_label: str = "当前作用域",
wrapper: Callable[..., set[str] | None] | None = None,
) -> set[str] | None:
"""Open the selector. Return names, or None when cancelled."""
@@ -41,36 +76,103 @@ def select_skills(
except ImportError as exc:
raise RuntimeError("当前 Python 环境不支持 curses,无法打开交互界面") from exc
selected = {choice.name for choice in choices if choice.installed}
selected = {
choice.name
for choice in choices
if choice.installed and not choice.children
}
def mark_for(choice: SkillChoice) -> str:
if not choice.children:
return "x" if choice.name in selected else " "
selected_children = selected.intersection(choice.children)
if len(selected_children) == len(choice.children):
return "x"
return "-" if selected_children else " "
def draw(stdscr: object) -> set[str] | None:
curses.curs_set(0)
stdscr.keypad(True)
current = 0
query = ""
source_attr = curses.A_NORMAL
status_attr = curses.A_NORMAL
description_attr = curses.A_DIM
try:
if curses.has_colors():
curses.start_color()
curses.use_default_colors()
curses.init_pair(1, curses.COLOR_CYAN, -1)
curses.init_pair(2, curses.COLOR_GREEN, -1)
source_attr = curses.color_pair(1)
status_attr = curses.color_pair(2)
except curses.error:
pass
while True:
visible = filter_choices(choices, query)
current = min(current, max(0, len(visible) - 1))
stdscr.erase()
height, width = stdscr.getmaxyx()
header = "↑/↓ 移动 Space 勾选 / 搜索 Enter 安装 q 取消"
stdscr.addnstr(0, 0, header, max(0, width - 1))
text_width = max(0, width - 1)
stdscr.addnstr(
0,
0,
fit_to_width(f"安装目标: {scope_label}", text_width),
text_width,
)
header = "↑↓ 移动 Space 选择 / 搜索 Enter 安装 q 退出"
stdscr.addnstr(1, 0, fit_to_width(header, text_width), text_width)
if query:
stdscr.addnstr(1, 0, f"搜索: {query}", max(0, width - 1))
stdscr.addnstr(
2,
0,
fit_to_width(f"搜索: {query}", text_width),
text_width,
)
rows = max(1, height - 4)
start = max(0, current - rows + 1)
for row, choice in enumerate(visible[start : start + rows], start=2):
index = start + row - 2
mark = "x" if choice.name in selected else " "
suffix = f" · {choice.description}" if choice.description else ""
line = f"[{mark}] {choice.name} {choice.kind}{suffix}"
attr = curses.A_REVERSE if index == current else curses.A_NORMAL
stdscr.addnstr(row, 0, line, max(0, width - 1), attr)
item_capacity = max(1, (height - 4) // 3)
start = max(0, current - item_capacity + 1)
for offset, choice in enumerate(visible[start : start + item_capacity]):
index = start + offset
row = 3 + offset * 3
mark = mark_for(choice)
description = f" {choice.description}" if choice.description else ""
focus_attr = curses.A_REVERSE if index == current else curses.A_NORMAL
parts = [
(f"{' ' * choice.indent}[{mark}] ", focus_attr),
(choice.name, focus_attr | curses.A_BOLD),
(f" {choice.kind}", focus_attr | source_attr),
]
if choice.readonly_status:
parts.append(
(f" {choice.readonly_status}", focus_attr | status_attr)
)
column = 0
for text, attr in parts:
available = text_width - column
if available <= 0:
break
fitted = fit_to_width(text, available)
stdscr.addnstr(row, column, fitted, available, attr)
column += display_width(fitted)
stdscr.addnstr(
row + 1,
0,
fit_to_width(description, text_width),
text_width,
focus_attr | description_attr,
)
footer = f"已选择 {len(selected)} 项"
stdscr.addnstr(height - 1, 0, footer, max(0, width - 1))
footer = (
f"已选择 {len(selected)} 项 · 取消选择不卸载,卸载用 skiff remove"
)
stdscr.addnstr(
height - 1,
0,
fit_to_width(footer, text_width),
text_width,
)
stdscr.refresh()
key = stdscr.get_wch()
@@ -83,11 +185,18 @@ def select_skills(
elif key == curses.KEY_DOWN and visible:
current = (current + 1) % len(visible)
elif key == " " and visible:
name = visible[current].name
selected.symmetric_difference_update({name})
choice = visible[current]
if choice.children:
children = set(choice.children)
if children.issubset(selected):
selected.difference_update(children)
else:
selected.update(children)
else:
selected.symmetric_difference_update({choice.name})
elif key == "/":
curses.curs_set(1)
query = _read_query(stdscr, curses, width)
query = _read_query(stdscr, curses, width, row=2)
curses.curs_set(0)
current = 0
@@ -95,12 +204,18 @@ def select_skills(
return runner(draw)
def _read_query(stdscr: object, curses: object, width: int) -> str:
def _read_query(stdscr: object, curses: object, width: int, *, row: int = 1) -> str:
query = ""
while True:
stdscr.move(1, 0)
stdscr.move(row, 0)
stdscr.clrtoeol()
stdscr.addnstr(1, 0, f"搜索: {query}", max(0, width - 1))
text_width = max(0, width - 1)
stdscr.addnstr(
row,
0,
fit_to_width(f"搜索: {query}", text_width),
text_width,
)
stdscr.refresh()
key = stdscr.get_wch()
if key in ("\n", "\r") or key == curses.KEY_ENTER:
+95 -50
View File
@@ -6,15 +6,19 @@ import re
from pathlib import Path
from skiff.paths import SKILLS_DIR, TEMPLATE_DIR, ensure_skills_home
from skiff.registry import (
external_collection_skill_path,
external_skill_path,
load_registry,
from skiff.catalog import (
catalog_skill_path,
discover_catalog_skills,
load_catalog,
)
from skiff.sources import (
discover_source_skills,
list_source_skills,
load_sources,
)
from skiff.sources import list_source_skills, load_sources, source_skills_root
def list_owned_skills() -> list[str]:
def list_builtin_skills() -> list[str]:
ensure_skills_home()
if not SKILLS_DIR.is_dir():
return []
@@ -29,19 +33,31 @@ def list_owned_skills() -> list[str]:
return names
def owned_skill_path(name: str) -> Path:
def builtin_skill_path(name: str) -> Path:
path = SKILLS_DIR / name
if not (path / "SKILL.md").is_file():
raise SystemExit(f"自研 skill 不存在: {name}")
raise SystemExit(f"builtin skill 不存在: {name}")
return path
def normalize_source(source: str | None) -> str | None:
if source == "owned":
return "builtin"
if source == "registry":
return "catalog"
if source and source.startswith("registry:"):
return f"catalog:{source.split(':', 1)[1]}"
return source
def split_skill_spec(spec: str, source: str | None = None) -> tuple[str, str | None]:
if "/" not in spec:
return spec, source
return spec, normalize_source(source)
qualified_source, name = spec.split("/", 1)
if not qualified_source or not name or "/" in name:
raise SystemExit(f"skill 限定名称无效: {spec!r}(应为 source/name)")
qualified_source = normalize_source(qualified_source)
source = normalize_source(source)
if source and source != qualified_source:
raise SystemExit(
f"skill 来源冲突: {spec!r} 与 --source {source!r} 不一致"
@@ -63,42 +79,36 @@ def resolve_skill_source(name: str, *, source: str | None = None) -> tuple[Path,
ensure_skills_home()
name, source = split_skill_spec(name, source)
owned = SKILLS_DIR / name
if source == "owned":
if not (owned / "SKILL.md").is_file():
raise SystemExit(f"owned source 中找不到 skill: {name}")
return owned, "owned"
builtin = SKILLS_DIR / name
if source == "builtin":
if not (builtin / "SKILL.md").is_file():
raise SystemExit(f"builtin source 中找不到 skill: {name}")
return builtin, "builtin"
registry = load_registry()
catalog = load_catalog()
sources = load_sources()
registry_collection = None
if source and source.startswith("registry:"):
registry_collection = source.split(":", 1)[1]
elif (
source
and source not in ("owned", "registry")
and source in registry
and source not in sources
):
registry_collection = source
if registry_collection:
path = external_collection_skill_path(
registry_collection,
name,
registry[registry_collection],
)
return path, f"registry:{registry_collection}"
if source and source.startswith("catalog:"):
provider = source.split(":", 1)[1]
if provider not in catalog:
raise SystemExit(f"catalog 中找不到 source: {provider}")
skills = discover_catalog_skills(provider, catalog[provider])
if name not in skills:
available = ", ".join(skills) or "(无)"
raise SystemExit(
f"catalog source {provider!r} 中找不到 skill {name!r}。可用: {available}"
)
return skills[name], f"catalog:{provider}"
if source == "registry":
if name not in registry:
raise SystemExit(f"registry 中找不到 skill: {name}")
path = external_skill_path(name, registry[name])
if source == "catalog":
if name not in catalog:
raise SystemExit(f"catalog 中找不到 skill source: {name}")
path = catalog_skill_path(name, catalog[name])
if not (path / "SKILL.md").is_file():
raise SystemExit(
f"外部 skill {name!r} 尚未 fetch 或 path 中缺少 SKILL.md。"
f"catalog skill {name!r} 尚未 fetch 或 path 中缺少 SKILL.md。"
f"请运行: skiff fetch {name}"
)
return path, "registry"
return path, f"catalog:{name}"
if source:
if source not in sources:
@@ -106,28 +116,47 @@ def resolve_skill_source(name: str, *, source: str | None = None) -> tuple[Path,
f"项目依赖 source {source!r},但本机尚未配置。"
f"请运行: skiff source add {source} <repo>"
)
path = source_skills_root(source, sources[source]) / name
if not (path / "SKILL.md").is_file():
skills = discover_source_skills(source, sources[source])
if name not in skills:
raise SystemExit(f"source {source!r} 中找不到 skill: {name}")
return path, source
return skills[name], source
candidates: list[tuple[Path, str]] = []
if (owned / "SKILL.md").is_file():
candidates.append((owned, "owned"))
if name in registry:
candidates.append((external_skill_path(name, registry[name]), "registry"))
if (builtin / "SKILL.md").is_file():
candidates.append((builtin, "builtin"))
if name in catalog:
path = catalog_skill_path(name, catalog[name])
if (path / "SKILL.md").is_file() or not path.exists():
candidates.append((path, f"catalog:{name}"))
for provider, entry in catalog.items():
if provider == name:
continue
skills = discover_catalog_skills(provider, entry)
if name in skills:
candidates.append((skills[name], f"catalog:{provider}"))
for source_name, entry in sources.items():
path = source_skills_root(source_name, entry) / name
if (path / "SKILL.md").is_file():
candidates.append((path, source_name))
skills = discover_source_skills(source_name, entry)
if name in skills:
candidates.append((skills[name], source_name))
unique_candidates: list[tuple[Path, str]] = []
seen_paths: set[Path] = set()
for path, candidate_source in candidates:
resolved = path.resolve()
if resolved in seen_paths:
continue
seen_paths.add(resolved)
unique_candidates.append((path, candidate_source))
candidates = unique_candidates
if len(candidates) > 1:
choices = ", ".join(f"{candidate_source}/{name}" for _, candidate_source in candidates)
raise SystemExit(f"skill 名称存在多个来源,请明确指定: {choices}")
if candidates:
path, resolved_source = candidates[0]
if resolved_source == "registry" and not path.exists():
raise SystemExit(f"外部 skill {name!r} 尚未 fetch。请先运行: skiff fetch {name}")
if resolved_source.startswith("catalog:") and not path.exists():
provider = resolved_source.split(":", 1)[1]
raise SystemExit(f"catalog source {provider!r} 尚未 fetch。请先运行: skiff fetch {provider}")
return path, resolved_source
raise SystemExit(f"找不到 skill: {name}")
@@ -196,8 +225,24 @@ def validate_skill_dir(skill_dir: Path, expected_name: str) -> list[str]:
"""返回 skill 目录中的校验问题;空列表表示通过。"""
issues: list[str] = []
skill_md = skill_dir / "SKILL.md"
readme = skill_dir / "README.md"
if not skill_md.is_file():
return ["缺少 SKILL.md"]
if not readme.is_file():
issues.append("缺少 README.md(面向人类的使用说明)")
else:
readme_text = readme.read_text(encoding="utf-8")
if not readme_text.strip():
issues.append("README.md 为空")
if re.search(r"^#\s+skill-name\s*$", readme_text, re.MULTILINE):
issues.append("README.md 存在模板占位内容: skill-name")
for placeholder in (
"用一句话告诉使用者这个 skill 能解决什么问题",
"给出用户可以直接说出的典型请求",
"给出一条可以直接交给 Agent 的示例请求",
):
if placeholder in readme_text:
issues.append(f"README.md 存在模板占位内容: {placeholder}")
text = skill_md.read_text(encoding="utf-8")
if not text.strip():
+217
View File
@@ -0,0 +1,217 @@
# Skill 来源模型
## 背景
skiff 当前使用 `owned` 表示本仓库 `skills/` 中维护的 Skill,同时还支持
`registry` 外部仓库和用户配置的 custom source。
这些名称混合了不同维度:
- `owned` 表示所有权。
- `registry` 表示来源配置存放在哪里。
- `external` 表示内容不在当前仓库。
- `collection` 表示一个来源中包含多个 Skill。
它们不能作为同一层级的互斥类型。例如 custom source 既可能来自外部 Git 仓库,
也可能是 collection;因此 `custom` 与 `external` 并不互斥。
本文建议将用户可见的来源注册方式统一为三类:
- `builtin`:随当前 skiff 仓库提供的 Skill。
- `catalog:<name>`:由 skiff 自带目录预先登记的来源。
- `custom:<name>`:用户在本机显式配置的命名来源。
来源注册方式与获取方式、仓库布局相互独立。builtin、catalog 和 custom 中的任意
来源都可以包含一个或多个 Skill;除 builtin 外,catalog 和 custom 都可以使用
Git 仓库或本地目录。
## 迁移前模型
```mermaid
flowchart TD
S[skiff 可发现的 Skills]
S --> O["owned<br/>~/.skills/skills/*"]
S --> C["custom source<br/>~/.config/skiff/config.yaml"]
S --> R["registry<br/>registry.yaml"]
O --> O1["本仓库维护<br/>随 skiff 一起分发"]
C --> C1["本地目录<br/>--local PATH"]
C --> C2["指定 Git 仓库<br/>repo + skills_path"]
R --> R1["外部单 Skill 仓库"]
R --> R2["外部 Skill Collection"]
R1 --> E["~/.local/share/skills/externals/"]
R2 --> E
```
迁移前实现中:
- `list` 和 `status` 支持 owned、registry 和 custom source。
- `resolve_skill_source` 可以解析三种来源并处理同名歧义。
- `select` 只组装 owned 和 registry 条目,尚未展示 custom source。
- `.skills.yaml` 默认将未声明来源的 Skill 解释为 `owned`。
- custom source 的 `skills_path` 已经可以包含多个 Skill,本质上也是 collection。
## 现行模型
```mermaid
flowchart TD
A[Skill Provider]
A --> B["builtin<br/>仓库内隐式注册"]
A --> C["catalog:waza<br/>skiff 预置目录"]
A --> D["custom:company<br/>用户本机配置"]
B --> B1["skills/ack"]
B --> B2["skills/skiff"]
B --> B3["skills/deb-publisher"]
C --> C1["Git 或本地目录"]
C1 --> C2["单 Skill"]
C1 --> C3["Collection"]
C3 --> C4["waza/think"]
C3 --> C5["waza/check"]
D --> D1["Git 或本地目录"]
D1 --> D2["单 Skill"]
D1 --> D3["Collection"]
D3 --> D4["company/security-review"]
D3 --> D5["company/release"]
```
### 来源注册方式
| 类型 | 含义 | 配置来源 | 用户界面展示 |
| --- | --- | --- | --- |
| `builtin` | 随当前 skiff 仓库提供 | `skills/` | `builtin` |
| `catalog` | skiff 预先登记、所有用户可发现的来源 | `catalog.yaml` | `catalog:<name>` |
| `custom` | 用户在本机显式注册的命名来源 | `~/.config/skiff/config.yaml` | `custom:<name>` |
`builtin` 比 `owned` 更适合作为用户可见名称,因为它表达 Skill 的分发位置和可用
方式,而不是仓库的所有权关系。
`catalog` 比 `registry` 更准确:当前文件只是 skiff 随仓库维护的预置来源目录,
并不是远程注册中心,也不是一种 Skill 来源协议。
### 三个正交维度
每个 provider 应由三个维度描述:
```text
registration: builtin | catalog | custom
transport: bundled | local | git
layout: single | collection
```
custom source 示例:
```text
registration: custom
name: company
transport: local | git
layout: single | collection
```
catalog entry 示例:
```text
registration: catalog
name: waza
transport: local | git
layout: single | collection
```
`external` 只适合描述物理位置,可作为 catalog 和部分 custom provider 的统称,
不应成为与 builtin、custom 并列的持久化类型。
## `select` 展示
`skiff select` 应同时展示 builtin、catalog 和 custom Skill:
```text
[ ] ack builtin
[ ] deb-publisher builtin
[-] company custom source
[ ] company/release custom:company
[ ] company/security-review custom:company
[-] waza catalog source
[ ] waza/check catalog:waza
[ ] waza/think catalog:waza
```
建议遵循以下规则:
- builtin Skill 直接显示 Skill 名称。
- catalog 和 custom collection 都使用来源名称作为父节点。
- catalog 和 custom Skill 都使用 `<source>/<skill>` 作为选择器键。
- 单 Skill 来源可以省略父节点,但仍显示注册方式和来源名称。
- 不同来源安装后名称相同时,继续拒绝同时选择,避免覆盖同一安装路径。
## 配置表示
新写入的 `.skills.yaml` 使用以下形式:
```yaml
skills:
- name: ack
source: builtin
- name: security-review
source: company
- name: think
source: catalog:waza
```
custom source 在 manifest 中继续保存其逻辑名称,例如 `company`。这样不同机器可以
独立配置仓库地址,而项目只依赖稳定的来源名称。
## 兼容策略
用户可见术语已经调整,并保留以下读取兼容,避免已有项目立即失效:
1. 对外文档、CLI 输出和 selector 统一使用 `builtin`、`catalog:<name>` 和
`custom:<name>`。
2. 新生成的 `.skills.yaml` 对内置 Skill 写入 `source: builtin`。
3. 读取旧 manifest 时继续接受 `source: owned`,并在解析时归一化为 `builtin`。
4. CLI 参数在过渡期继续接受 `--source owned`,但帮助和输出只推荐 `builtin`。
5. 读取旧 manifest 中的 `source: registry` 和 `registry: <name>`,归一化为
`catalog:<name>`。
6. 主文件使用 `catalog.yaml`;不存在时兼容读取旧 `registry.yaml`。
7. custom source 的逻辑名称和现有 `config.yaml` 结构保持不变。
8. 将 `builtin`、`catalog` 和兼容别名 `owned`、`registry` 设为 custom source
保留字。
9. `select` 同步接入 custom Skill,并让 custom collection 与 catalog collection
使用相同的父子展示逻辑。
## 实现范围
当前实现覆盖:
- `skiff/skills.py`:来源解析、归一化和 builtin 命名。
- `skiff/project.py`:manifest 默认值、序列化与旧值兼容。
- `skiff/sources.py`:来源保留字。
- `skiff/catalog.py`:catalog 配置、checkout 与 Skill 发现。
- `skiff/cli.py`:`list`、`status`、`add`、`select` 和输出文案。
- `skiff/selector.py`:统一 catalog/custom collection 的父子展示。
- CLI 与来源解析测试。
- 根 README、`skiff/README.md`、`skills/skiff/SKILL.md` 和相关示例。
## 验证要求
实施完成后至少覆盖:
- builtin Skill 可以通过新名称列出、选择、安装和写入 manifest。
- 旧的 `source: owned` manifest 仍可同步。
- custom local source 和 custom Git source 都出现在 selector 中。
- catalog 单 Skill 和 Collection 都保持可选择。
- custom 单 Skill 和 Collection 都保持可选择。
- 多来源同名 Skill 必须显式指定来源。
- 两个选择最终安装为同一个名称时,selector 拒绝冲突。
- `list`、`status`、`select` 对同一来源使用一致术语。
## 设计前提
迁移前 `registry.yaml` 的真实职责只是维护 skiff 预置的来源目录,而不是提供远程
发布、版本解析或可信签名等注册中心能力,因此现已改为 `catalog.yaml`。如果未来
实现真正的远程 registry,应单独定义其协议和与 catalog 的同步关系,不复用旧名称。
+26 -8
View File
@@ -9,7 +9,7 @@ from typing import Any
from skiff import yaml_io
from skiff.paths import CONFIG_FILE, SOURCES_DIR
RESERVED_SOURCES = {"owned", "registry"}
RESERVED_SOURCES = {"builtin", "catalog", "owned", "registry"}
def validate_source_name(name: str) -> None:
@@ -68,15 +68,23 @@ def source_skills_root(name: str, entry: dict[str, Any]) -> Path:
return root
def list_source_skills(name: str, entry: dict[str, Any]) -> list[str]:
def discover_source_skills(name: str, entry: dict[str, Any]) -> dict[str, Path]:
root = source_skills_root(name, entry)
if (root / "SKILL.md").is_file():
return {name: root}
if not root.is_dir():
return []
return [
item.name
return {}
return {
item.name: item
for item in sorted(root.iterdir())
if item.is_dir() and not item.name.startswith("_") and (item / "SKILL.md").is_file()
]
if item.is_dir()
and not item.name.startswith("_")
and (item / "SKILL.md").is_file()
}
def list_source_skills(name: str, entry: dict[str, Any]) -> list[str]:
return list(discover_source_skills(name, entry))
def fetch_source(name: str, entry: dict[str, Any]) -> Path:
@@ -100,7 +108,17 @@ def fetch_source(name: str, entry: dict[str, Any]) -> Path:
else:
checkout.parent.mkdir(parents=True, exist_ok=True)
subprocess.run(
["git", "clone", "--branch", ref, "--", str(repo), str(checkout)],
[
"git",
"clone",
"--depth",
"1",
"--branch",
ref,
"--",
str(repo),
str(checkout),
],
check=True,
)
return checkout
+4 -1
View File
@@ -68,6 +68,9 @@ def find_repo_root(start: Path | None = None) -> Path | None:
for directory in [start, *start.parents]:
if (directory / ".skills.yaml").is_file():
return directory
if (directory / "skills").is_dir() and (directory / "registry.yaml").is_file():
if (directory / "skills").is_dir() and (
(directory / "catalog.yaml").is_file()
or (directory / "registry.yaml").is_file()
):
return directory
return None
+28
View File
@@ -0,0 +1,28 @@
# skill-name
用一句话告诉使用者这个 skill 能解决什么问题。
## 什么时候使用
- 给出用户可以直接说出的典型请求。
- 说明适用范围。
## 使用前准备
- 列出需要用户提前提供或确认的信息、权限和依赖。
## 使用示例
```text
给出一条可以直接交给 Agent 的示例请求。
```
## Agent 会做什么
1. 用面向使用者的语言概括主要步骤。
2. 说明可能发生的外部写操作或重要边界。
## 如何判断完成
- 说明使用者最终会得到什么,以及如何确认结果正确。
+85
View File
@@ -0,0 +1,85 @@
# ACK
ACK 是一个显式调用的 Agent Skill,用三种独立角色运行工程协作闭环:
- Coordinator 拆解需求、派发任务并终检。
- Developer 实现并执行白盒验证。
- Test 独立执行黑盒复测。
关键约束是验证者不等于实现者。每个任务最多修复三轮,仍未通过时记录为
`leftover`,然后继续处理其它任务。
## 安装
全局安装:
```bash
skiff add ack -g
```
或只安装到当前项目:
```bash
skiff add ack
```
ACK 只在用户显式调用 `/ack` 或 `$ack` 时运行。
## 初始化项目
```bash
skiff init ack
skiff init ack --project ~/code/my-app
```
初始化后,项目只保存自己的 ACK 状态:
```text
docs/ack/
├── project.md
└── tasks.yaml
```
不会在项目中复制或链接 ACK Skill。通用规范、模板和脚本始终从已安装的 Skill
目录读取。
## Skill 结构
```text
skills/ack/
├── SKILL.md
├── README.md
├── VERSION
├── references/ # 三角色规范、闭环流程和初始化说明
├── templates/ # project.md 与 tasks.yaml 模板和 schema
├── examples/ # 完整示例
└── scripts/ # tasks.yaml 与 worker 命令校验器
```
`SKILL.md` 是 Agent 的工作流入口。`references/` 是按需读取的稳定规范;
`docs/ack/project.md` 只保存当前项目的命令、路径和权限差异;
`docs/ack/tasks.yaml` 保存当前任务状态。
## 检查任务板
Agent 会从当前 ACK Skill 目录解析校验脚本:
```bash
python3 <ack-skill-dir>/scripts/validate_tasks.py docs/ack/tasks.yaml
```
## 开始一个需求
初始化完成后可以直接说:
```text
/ack 处理这个需求:<一句话需求>
```
Coordinator 会先读取项目状态和 `references/kickoff.md`,生成产品文档、任务拆分与
可观测验收信号;用户确认后才派发实现和复测。
## 版本
当前 Skill 版本见 `VERSION`。新项目在 `tasks.yaml` 中记录 `ackVersion`。旧项目的
`kitVersion` 可以继续读取,但建议迁移为 `ackVersion`。
+98
View File
@@ -0,0 +1,98 @@
---
name: ack
description: >-
初始化、检查并运行 ACK 三角色协作闭环。仅在用户显式调用 /ack 或 $ack,并要求
初始化 ACK、检查 docs/ack 配置、按 ACK 规划需求或指挥 Coordinator/Developer/Test
工作时使用。
---
# ACK 项目协作入口
本 Skill 是 ACK 的完整能力包:`references/` 保存通用规范,`templates/` 保存项目
状态模板,`scripts/` 保存校验工具。目标项目只在 `docs/ack/` 保存 `project.md` 和
`tasks.yaml`,不要复制或链接 Skill 内容。
开始时解析当前 `SKILL.md` 所在目录,记为 `<ack-skill-dir>`。所有通用规范、模板和
脚本都相对此目录访问,不依赖固定的全局安装路径。
## 选择模式
- 用户要求初始化、接入或安装 ACK:执行“初始化”。
- 用户要求检查 ACK 是否可用、配置是否完整:执行“检查”。
- 用户要求用 ACK 做需求、修复问题或继续任务:执行“工作”。
始终先解析真实项目根目录。优先使用 `git rev-parse --show-toplevel`;不是 Git
项目时使用用户指定目录或当前目录。不要修改项目的 `AGENTS.md`、`CLAUDE.md`
或其它 Agent 指令文件。
## 初始化
1. 确认 `skiff` 可执行,并检查 `<project>/docs/ack` 是否存在。
2. 不存在时执行:
```bash
skiff init ack --project <project-root>
```
该命令从本 Skill 的 `templates/` 生成项目状态,不会在项目中创建 Skill
软链接或资源副本。
3. 如果 `docs/ack` 已存在,不重复初始化、不覆盖文件;转入“检查”,报告缺失项并
只补用户授权且能安全确定的内容。
4. 读取项目的公开配置和文档,例如 README、语言清单、包管理清单、测试配置与
CI,确定项目名、技术栈、源码/规格/测试路径及真实可执行命令。
5. 完善 `docs/ack/project.md`:
- 用实际项目值替换全部占位符。
- 无服务地址时把 Base URL 写为 `n/a`,不要虚构端口。
- 无法从项目证据确定的命令写为 `n/a`,并在结果中列为待配置项。
- 只写项目差异,不复制 `references/` 中的通用规范。
6. 完善 `docs/ack/tasks.yaml` 的项目信息。纯初始化且用户没有提供真实任务时,
删除模板示例任务并保留 `tasks: []`;不要虚构需求或缺陷。
7. 更新 `updatedAt`,并运行:
```bash
python3 <ack-skill-dir>/scripts/validate_tasks.py docs/ack/tasks.yaml
```
8. 检查 `project.md` 与 `tasks.yaml` 是否仍有 `<...>` 占位符。结构校验通过且
必填项目事实完整时才称“初始化完成”;否则称“部分完成”并列出缺失值。
9. 报告创建的路径、检测到的命令、校验结果和下一步。除非用户明确要求,不提交、
不推送。
## 检查
1. 检查以下路径:
- `docs/ack/project.md`
- `docs/ack/tasks.yaml`
2. 读取 `<ack-skill-dir>/VERSION`,对比 `tasks.yaml` 的 `ackVersion`。旧项目只有
`ackVersion` 时仍可读取,但建议迁移为 `ackVersion`。
3. 查找未替换占位符,并核对项目路径、覆盖层路径、Developer 白盒命令、Test
黑盒命令和 Base URL。
4. 使用 `<ack-skill-dir>/scripts/validate_tasks.py` 校验任务板。只报告证据明确的
问题,不因可选字段缺失而宣称失败。
5. 检查不会自动修复或覆盖现有配置;用户明确要求修复后再修改。
## 工作
1. 若 `docs/ack` 不存在,停止并建议先用 `/ack` 初始化;不要静默初始化。
2. 依次读取:
- `docs/ack/project.md`
- `docs/ack/tasks.yaml`
- `<ack-skill-dir>/references/kickoff.md`
- kickoff 指定且与当前任务相关的 references 文件
3. 当前会话担任 Coordinator,遵守项目覆盖层中的命令、路径权限、模型路由和
worker 复用规则。项目覆盖层优先于通用示例命令。
4. 新需求先写产品文档、任务拆分与可观测验收信号,更新 `tasks.yaml` 并校验,
然后交给用户确认;确认前不派发实现。
5. 用户已确认的任务按 ACK 闭环执行:Developer 实现与白盒验证,Test 独立黑盒
复测,Coordinator 读取证据终检并唯一写入 `tasks.yaml`。
6. 不把 `worker_done` 或 Test 自报成功直接当作完成。每项最多三轮,仍失败则记录
`leftover` 并继续其它任务。
## 边界
- 不修改或追加任何项目 Agent 指令文件,包括 `AGENTS.md`。
- 不在项目中维护第二份 ACK 通用规范、模板或任务 schema。
- 不猜测项目命令、服务地址、worker handle 或模型名称。
- 不覆盖已有 `docs/ack` 文件,不擅自提交、推送、创建终端或新 worktree。
- 项目只保存 `docs/ack/project.md` 和 `docs/ack/tasks.yaml`;通用资源始终从当前
ACK Skill 目录读取。
+6
View File
@@ -0,0 +1,6 @@
interface:
display_name: "ACK"
short_description: "初始化、检查并运行项目里的 ACK 三角色协作闭环"
default_prompt: "Use $ack to initialize ACK for this project or coordinate work from docs/ack."
policy:
allow_implicit_invocation: false
@@ -1,9 +1,9 @@
# notes-web Agent 协作协议(示例,项目覆盖层)
> 本项目基于 ack v0.8.1。
> 稳定规范引用 `docs/ack/kit/core/`,本文件只填项目差异。
> 通用规范由 `/ack` 从 Skill 自身的 `references/` 读取,本文件只填项目差异。
> 覆盖层文件放在 `docs/ack/project.md`,不占用 `AGENTS.md`。
> 目录下只有一个软链接 `kit/` + `project.md` + `tasks.yaml`。
> `docs/ack/` 只保存 `project.md` 与 `tasks.yaml`。
## 项目概览
@@ -16,13 +16,13 @@
## 稳定规范(引用,不重复)
- 开始一个需求(启动手册):`docs/ack/kit/core/kickoff.md`
- 角色 / 权限 / 状态机 / 完成定义:`docs/ack/kit/core/roles-and-permissions.md`
- 模型档位与升级规则:`docs/ack/kit/core/model-routing.md`
- 闭环流程(含手动模式、worktree 对齐):`docs/ack/kit/core/closed-loop.md`
- 优化方法(验收信号、三轮策略):`docs/ack/kit/core/optimization-method.md`
- 派发 prompt 模板:`docs/ack/kit/core/prompt-templates.md`
- Orca 编排命令:`docs/ack/kit/core/orca-adapter.md`
- 开始一个需求(启动手册):`references/kickoff.md`
- 角色 / 权限 / 状态机 / 完成定义:`references/roles-and-permissions.md`
- 模型档位与升级规则:`references/model-routing.md`
- 闭环流程(含手动模式、worktree 对齐):`references/closed-loop.md`
- 优化方法(验收信号、三轮策略):`references/optimization-method.md`
- 派发 prompt 模板:`references/prompt-templates.md`
- Orca 编排命令:`references/orca-adapter.md`
## 模型档位
@@ -63,16 +63,12 @@ curl -s -X POST http://localhost:5173/api/fix/preview -d @fixtures/preview.json
# 浏览器回归:tests/browser/cases/*.md
```
任务板校验:
任务板校验由 `/ack` 使用 Skill 自带的 `scripts/validate_tasks.py` 执行。
```bash
python3 docs/ack/kit/scripts/validate_tasks.py docs/ack/tasks.yaml
```
## 硬规则(其余见 kit/core/)
## 硬规则(其余见 references/)
- 三角色独立:Coordinator 只编排、Test 只验证、Developer 只实现。
- 模型分层:Coordinator 强模型不跑测试,Test/Developer 中低模型(见 kit/core/model-routing.md)。
- 模型分层:Coordinator 强模型不跑测试,Test/Developer 中低模型(见 references/model-routing.md)。
- `worker_done` 与复测报告都不等于完成,必须 Test 独立复测 + Coordinator 终检后才能 `verified`。
- 只有 Coordinator 写 `tasks.yaml`;Test 与 Developer 只读。
- 每个任务最多派发 3 轮,仍不过标记 `leftover` 并继续。
@@ -2,7 +2,7 @@
version: 1
updatedAt: "2026-07-06T09:40:00+08:00"
source: "Coordinator (PM) Agent"
kitVersion: "0.3.0"
ackVersion: "0.8.1"
project:
name: "notes-web"
repoPath: "/home/dev/notes-web"
@@ -0,0 +1,51 @@
# ACK 接入清单
## 安装与初始化
- [ ] ACK Skill 已全局安装或安装到当前项目。
- [ ] 已运行 `skiff init ack --project <project-root>`。
- [ ] `docs/ack/` 只包含项目自己的 `project.md` 与 `tasks.yaml`。
- [ ] 项目中没有 ACK Skill 的复制目录或 `kit`、`framework` 软链接。
- [ ] `tasks.yaml` 使用 `ackVersion` 记录 ACK Skill 版本。
## 项目覆盖层
- [ ] `project.md` 只保存项目差异,不复制 Skill 的通用规范。
- [ ] `tasks.yaml` 的 `project.overlayFile` 指向实际覆盖层。
- [ ] 技术栈、运行、构建、单测和集成测试命令均来自项目证据。
- [ ] Coordinator、Developer、Test 的模型档位和升级规则已明确。
## 路径权限
- [ ] Coordinator 可写规格和任务板。
- [ ] Test 可写集成测试与复测记录,但不能改源码。
- [ ] Developer 可写源码与单元测试,但不能改规格或黑盒验收。
- [ ] 私有配置只读且不提交。
- [ ] `tasks.yaml` 只有 Coordinator 写。
## 任务板
- [ ] 已替换项目名、仓库路径、Base URL、worktree 和覆盖层路径。
- [ ] 没有真实任务时使用 `tasks: []`。
- [ ] 真实任务的验收是可观测信号。
- [ ] 已运行 `<ack-skill-dir>/scripts/validate_tasks.py` 并通过。
## 编排
- [ ] 已选择 Orca 或手动模式。
- [ ] 派发前优先复用同 worktree、同角色、同配置的空闲 worker。
- [ ] Developer 与 Test 的启动命令通过校验。
- [ ] 多 worktree 场景只有一个权威 `tasks.yaml`。
- [ ] Test 使用的服务来自正确 worktree。
## 闭环
- [ ] `worker_done` 不直接视为完成。
- [ ] Test 独立复测后由 Coordinator 读取证据终检。
- [ ] 每轮写入 `dispatch.rounds`。
- [ ] 三轮失败后标记 `leftover` 并填写原因。
- [ ] `leftover` 不阻塞其它任务。
首次接入建议选择一个低风险问题跑完整闭环。项目差异写回
`docs/ack/project.md`;通用问题回流到 ACK Skill 的 `references/`、`templates/`
或 `scripts/`,并更新 `VERSION`。
+92
View File
@@ -0,0 +1,92 @@
# 在新项目初始化 ACK
本文件说明如何把 ACK 的项目状态初始化到目标项目。ACK Skill 自身已经通过 Agent
的 Skill 机制安装;项目不复制、不链接 Skill 内容。
## 前提与边界
开始前确认:
1. 目标项目根目录。
2. ACK Skill 已全局安装或安装到当前项目。
3. `skiff` 命令可用。
不要覆盖已有的 `docs/ack/project.md`、`docs/ack/tasks.yaml`、`AGENTS.md` 或其它
Agent 指令文件。不要把 token、`.env` 内容或其它私有配置写入 ACK 项目状态。
## 初始化
在目标项目执行:
```bash
skiff init ack
```
或显式指定项目:
```bash
skiff init ack --project <project-root>
```
命令从 ACK Skill 自带的 `templates/` 生成:
```text
docs/ack/
├── project.md
└── tasks.yaml
```
如果任一目标文件已经存在,命令会拒绝覆盖。初始化过程不会创建 `kit`、
`framework` 或其它指向 Skill 的软链接。
## 完善项目覆盖层
编辑 `docs/ack/project.md`,填入:
- 项目名、技术栈、运行命令和 Base URL。
- Coordinator、Developer、Test 的实际模型档位。
- 规格、集成测试、源码、单元测试和私有配置的路径权限。
- Developer 白盒验证命令。
- Test 黑盒复测命令。
无法从项目证据确定的值写为 `n/a`,不要猜测。
## 完善任务板
编辑 `docs/ack/tasks.yaml`:
- `ackVersion` 使用 ACK Skill 的 `VERSION`。
- `updatedAt` 使用当前带时区时间。
- `project.name`、`repoPath`、`devWorktree` 和 `overlayFile` 使用真实值。
- 非服务项目的 `baseUrl` 写为 `n/a`。
- 没有真实任务时使用 `tasks: []`,不要保留或虚构示例任务。
每个真实任务的验收必须是可观测信号,例如可见文本、API 状态和字段,或明确的交互
结果;不要只写“功能正常”。
## 校验
Agent 从当前 `SKILL.md` 解析 ACK Skill 目录后运行:
```bash
python3 <ack-skill-dir>/scripts/validate_tasks.py docs/ack/tasks.yaml
```
同时确认:
- `project.md` 和 `tasks.yaml` 没有未替换的 `<...>` 占位符。
- `project.overlayFile` 指向真实文件。
- Developer 与 Test 的验证命令可执行。
- 网站或 API 项目写清服务启动、重启和 Base URL。
## 初始化报告
完成后报告:
- 创建或确认的两个项目文件。
- 检测到的技术栈和验证命令。
- 任务板校验结果。
- 仍需用户补充的值。
只有结构校验通过且必填项目事实完整时才称“初始化完成”;否则称“部分完成”,并列出
具体阻塞项。除非用户明确要求,不提交、不推送。
@@ -14,9 +14,9 @@
```text
我要做一个新需求:<一句话需求>。
你作为 ack 的 Coordinator(PM),按 docs/ack/kit/core 的规范执行:
你作为 ack 的 Coordinator(PM),按 ACK Skill 的 references 规范执行:
1. 先读 docs/ack/project.md、docs/ack/kit/core/roles-and-permissions.md、closed-loop.md、optimization-method.md。
1. 先读 docs/ack/project.md、references/roles-and-permissions.md、closed-loop.md、optimization-method.md。
2. 写产品文档到 docs/(PRD / 交互 / 验收),把需求拆成任务,每个任务的验收写成可观测信号(可见文本 / API 结果 / 交互结果)。
3. 把任务写进 docs/ack/tasks.yaml(只有你写),跑 validate 校验结构。
4. 先把「产品文档 + 任务拆分 + 验收信号」给我确认,不要急着派发。
@@ -34,7 +34,7 @@
3. 校验结构:
```bash
python3 docs/ack/kit/scripts/validate_tasks.py docs/ack/tasks.yaml
python3 <ack-skill-dir>/scripts/validate_tasks.py docs/ack/tasks.yaml
```
4. **停下来给人确认**——这是强模型该花时间的地方,不要跳过。
@@ -58,14 +58,14 @@ python3 docs/ack/kit/scripts/validate_tasks.py docs/ack/tasks.yaml
# Codex 示例(模型与执行模式以项目覆盖层为准)
DEV_CMD='codex --dangerously-bypass-approvals-and-sandbox -m gpt-5.6-terra -c model_reasoning_effort=medium'
TEST_CMD='codex --dangerously-bypass-approvals-and-sandbox -m gpt-5.6-luna -c model_reasoning_effort=low'
python3 docs/ack/validate_worker_command.py --role developer --command "$DEV_CMD"
python3 docs/ack/validate_worker_command.py --role test --command "$TEST_CMD"
python3 <ack-skill-dir>/scripts/validate_worker_command.py --role developer --command "$DEV_CMD"
python3 <ack-skill-dir>/scripts/validate_worker_command.py --role test --command "$TEST_CMD"
orca terminal create --worktree active --command "$DEV_CMD" --title "ACK-DEV-CODEX-TERRA-1" --json
orca terminal create --worktree active --command "$TEST_CMD" --title "ACK-TEST-CODEX-LUNA-1" --json
# Cursor 示例
CURSOR_CMD='cursor-agent --yolo --model auto'
python3 docs/ack/validate_worker_command.py --role developer --command "$CURSOR_CMD"
python3 <ack-skill-dir>/scripts/validate_worker_command.py --role developer --command "$CURSOR_CMD"
orca terminal create --worktree active --command "$CURSOR_CMD" --title "ACK-DEV-CURSOR-AUTO-1" --json
```
@@ -71,7 +71,7 @@
执行模式也必须在创建 worker 时固定,并服从项目覆盖层或用户的明确要求。Cursor 的 YOLO 参数是 `--yolo`;Codex 的等价参数是 `--dangerously-bypass-approvals-and-sandbox`。不要把 Cursor 的 `--yolo` 原样传给 Codex,也不要把裸启动 `codex` 当作“自动选择了正确角色模型”。如果项目提供 worker 命令校验脚本,校验通过是 `terminal create` 的前置条件。
**本 kit 对 Cursor 的默认建议**:Test 与 Developer worker 用 `cursor-agent --yolo --model auto`(自动选型,天然偏向高效模型,符合"中低档位"意图,并按当前项目授权启用 YOLO);需要更强时改成具体强模型并保留 `--yolo`。Coordinator 作为强模型脑,通常就是发起编排的那个会话本身。
**ACK 对 Cursor 的默认建议**:Test 与 Developer worker 用 `cursor-agent --yolo --model auto`(自动选型,天然偏向高效模型,符合"中低档位"意图,并按当前项目授权启用 YOLO);需要更强时改成具体强模型并保留 `--yolo`。Coordinator 作为强模型脑,通常就是发起编排的那个会话本身。
### Codex 默认映射
@@ -8,7 +8,7 @@
## 角色模型(三角色)
本 kit 默认三个独立 Agent:**Coordinator 只编排、Test 只验证、Developer 只实现**。关键属性是**验证者 ≠ 实现者**:Developer 不能给自己盖章,验证权在独立的 Test。
ACK 默认三个独立 Agent:**Coordinator 只编排、Test 只验证、Developer 只实现**。关键属性是**验证者 ≠ 实现者**:Developer 不能给自己盖章,验证权在独立的 Test。
| 角色 | 主要职责 | 验证方式 | 不应做的事 |
|------|----------|----------|------------|
@@ -1,14 +1,13 @@
# <项目名> Agent 协作协议(项目覆盖层)
> 本项目基于 ack v<kit_version>(见 `docs/ack/kit/VERSION`)。
> 稳定规范引用 `docs/ack/kit/core/`,不复制其内容;本文件只填项目自己的差异。
> 本项目基于 ACK Skill v<ack_version>。通用规范由 `/ack` 从 Skill 自身的
> `references/` 读取;本文件只保存项目差异。
>
> **本文件是「项目覆盖层」,文件名可配置。** 默认放 `docs/ack/project.md`,
> 不占用 `AGENTS.md`,避免与团队已有的 `AGENTS.md` 约定冲突。
> 若希望 Agent 自动加载,可在项目 `AGENTS.md` 里加一行指向本文件,或直接把本文件命名为 `AGENTS.md`。
> 无论叫什么,都在 `tasks.yaml` 的 `project.overlayFile` 记录实际路径。
>
> 目录约定:`docs/ack/` 下只有一个软链接 `kit/`(指向共享框架),加本项目 `project.md` + `tasks.yaml`。
> `docs/ack/` 只保存本项目的 `project.md` 与 `tasks.yaml`,不复制或链接 Skill。
## 项目概览
@@ -19,17 +18,17 @@
- 任务板:`docs/ack/tasks.yaml`
- 覆盖层文件:`<overlay_file_path>`(默认 `docs/ack/project.md`)
## 稳定规范(不在此重复,直接引用)
## 通用规范(由 ACK Skill 按需读取)
- 开始一个需求(启动手册):`docs/ack/kit/core/kickoff.md`
- 角色模型 / 权限 / 状态机 / 完成定义:`docs/ack/kit/core/roles-and-permissions.md`
- 模型档位与升级规则:`docs/ack/kit/core/model-routing.md`
- 闭环流程(含手动模式、worktree 对齐):`docs/ack/kit/core/closed-loop.md`
- 优化方法(验收信号、三轮策略):`docs/ack/kit/core/optimization-method.md`
- 派发 prompt 模板:`docs/ack/kit/core/prompt-templates.md`
- Orca 编排命令(可选):`docs/ack/kit/core/orca-adapter.md`
- 开始需求:`references/kickoff.md`
- 角色、权限、状态机与完成定义:`references/roles-and-permissions.md`
- 模型档位与升级规则:`references/model-routing.md`
- 闭环流程与 worktree 对齐:`references/closed-loop.md`
- 验收信号与三轮策略:`references/optimization-method.md`
- 派发 prompt 模板:`references/prompt-templates.md`
- Orca 编排命令(可选):`references/orca-adapter.md`
## 模型档位(项目可覆盖,默认见 core/model-routing.md)
## 模型档位(项目可覆盖,默认见 references/model-routing.md)
| 角色 | 默认档位 | 本项目实际 |
|------|----------|------------|
@@ -68,16 +67,12 @@ Test 黑盒复测:
<browser_regression_command>
```
任务板校验:
任务板校验由 `/ack` 使用 Skill 自带的 `scripts/validate_tasks.py` 执行。
```bash
python3 docs/ack/kit/scripts/validate_tasks.py docs/ack/tasks.yaml
```
## 硬规则(其余见 kit/core/)
## 硬规则(其余见 references/)
- 三角色独立:Coordinator 只编排、Test 只验证、Developer 只实现(验证者 ≠ 实现者)。
- 模型分层:Coordinator 用强模型且不亲自跑测试,Test/Developer 用中低模型,必要时升级(见 kit/core/model-routing.md)。
- 模型分层:Coordinator 用强模型且不亲自跑测试,Test/Developer 用中低模型,必要时升级(见 references/model-routing.md)。
- `worker_done` 与复测报告都不等于完成。必须 Test 独立复测 + Coordinator 终检后才能 `verified`。
- 只有 Coordinator 写 `tasks.yaml`;Developer 与 Test 都只读,通过消息回报。
- 每个任务最多派发 3 轮,仍不过标记 `leftover` 并继续下一个。
@@ -1,7 +1,7 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "https://git.yumee.top/laily/skills/kits/ack/tasks.schema.json",
"title": "Agent Collaboration Kit task board",
"$id": "https://git.yumee.top/laily/skills/skills/ack/templates/tasks.schema.json",
"title": "ACK task board",
"description": "tasks.yaml 的权威结构。跨语言可用;参考校验实现见 scripts/validate_tasks.py。",
"type": "object",
"required": ["version", "project", "tasks"],
@@ -10,10 +10,14 @@
"version": { "type": "integer", "minimum": 1 },
"updatedAt": { "type": "string" },
"source": { "type": "string" },
"kitVersion": {
"ackVersion": {
"type": "string",
"description": "接入时所基于的 ack 版本,便于日后 diff 升级"
},
"kitVersion": {
"type": "string",
"description": "旧版 ACK 版本字段;兼容读取,新项目应使用 ackVersion"
},
"project": {
"type": "object",
"required": ["name"],
@@ -2,7 +2,7 @@
version: 1
updatedAt: "<YYYY-MM-DDTHH:mm:ss+TZ>"
source: "Coordinator (PM) Agent"
kitVersion: "<接入时的 ack 版本,见 kit 根 VERSION>"
ackVersion: "<接入时的 ack skill 版本>"
project:
name: "<project_name>"
repoPath: "<repo_path>"
+80
View File
@@ -0,0 +1,80 @@
# deb-publisher
帮助 Agent 复用项目已有的 DEB 打包与发布方式,完成构建、包检查、上传和发布验证。
## 什么时候使用
当你希望 Agent 处理以下任务时使用:
- “帮我构建这个项目的 DEB 包”
- “把 1.2.3 版本的 DEB 发布到包仓库”
- “看看项目里的 DEB 发布流程”
- “检查这个 DEB 是否可以发布”
只想在本机安装一个 `.deb`,或者要构建 RPM、Docker 镜像时,不需要使用这个 skill。
## 使用前准备
请准备或确认:
- 要构建或发布的版本号。
- 项目已经配置好构建脚本、Make 目标或 `debian/` 目录;上传脚本由 skill 自带,
不需要放进项目。
- 发布所需的令牌已经放入项目约定的环境变量或密钥系统。
- 如果要真实发布,明确告诉 Agent 目标仓库以及是否允许覆盖同版本。
不要把令牌直接粘贴到对话、命令参数或项目文件中。
## 使用示例
仅分析,不产生或上传包:
```text
看看这个项目的 DEB 是怎么构建和发布的。
```
只构建和检查,不上传:
```text
使用 deb-publisher 构建 1.2.3 的 DEB,检查包元数据和内容,不要上传。
```
构建并发布:
```text
使用 deb-publisher 构建并发布 1.2.3 的 amd64 DEB 到项目已配置的测试仓库。
```
发布已有产物:
```text
使用 deb-publisher 检查并发布 ./dist/example_1.2.3_amd64.deb。
```
## Agent 会做什么
Agent 会优先发现和复用项目已有入口,然后:
1. 确认版本、架构、产物路径、目标仓库和授权范围。
2. 构建 DEB,或定位你指定的已有产物。
3. 检查包的元数据、内容和 SHA-256。
4. 在你明确要求发布时,通过 skill 自带的 `scripts/upload_deb.sh` 上传。
5. 检查服务端响应,并在仓库支持时确认该版本已经可见。
## 如何判断完成
结果中应包含包名、版本、架构、产物路径、SHA-256,以及构建、上传和仓库可见性的
独立状态。异步索引尚未完成时,Agent 应明确说明“上传已接受,索引待更新”。
## 上传协议
自带脚本适用于接收 multipart 字段 `package`、`token` 和 `repository_name` 的
HTTP DEB 仓库,默认路径是 `/api/v2/upload/package`。使用以下环境变量:
- `DEB_SERVER_URL`:仓库服务地址。
- `DEB_TOKEN`:认证令牌。
- `DEB_REPOSITORY`:目标仓库名。
- `DEB_UPLOAD_PATH`:可选的上传路径覆盖。
令牌只从环境变量读取,不作为命令行参数传递。若你的仓库使用其他 API 协议,应先告诉
Agent,不要直接套用该脚本。
+150
View File
@@ -0,0 +1,150 @@
---
name: deb-publisher
description: >-
构建并发布 Debian DEB 包:发现项目已有的 Makefile 和打包入口,使用 skill 自带的
通用上传脚本提交包,校验包元数据与内容,并验证发布结果。触发词:构建 deb、
发布 deb、上传 deb、提交 deb、推送 apt 仓库、打 Debian 包。仅分析打包逻辑时也可使用,
但不会在未获授权时执行上传。
---
# DEB Publisher
复用项目已有发布约定,安全地完成“发现入口 → 构建 → 检查 → 上传 → 验证”。
## 何时使用
- 用户要求构建、发布、上传或提交 `.deb` 包。
- 用户要求梳理或接通项目现有的 DEB 发布流程。
- 用户要求把已经生成的 `.deb` 推送到 APT/DEB 包仓库。
## 不适用
- 只需要安装或卸载本地 DEB 包。
- 目标是 RPM、APK、容器镜像或语言包管理器。
- 用户只要求设计全新的 Debian 打包体系;此时应先完成方案设计。
## 工作流
### 1. 发现项目约定
从项目根目录查找,不预设文件位置:
```bash
rg -n -i --hidden --glob '!.git' \
'build-deb|upload-deb|publish-deb|dpkg-deb|debuild|curl.*deb|\.deb\b|aptly|reprepro'
```
重点检查:
- `Makefile`、CI 配置、`debian/`、构建脚本和发布文档。
- 版本号、包名、架构、产物目录和仓库名如何传入。
- 发布端点、认证方式以及发布是否由构建目标自动触发。
- 当前工作树和目标版本是否匹配。
优先复用已有构建入口。上传默认使用本 skill 的 `scripts/upload_deb.sh`,不要把它
复制到项目中;仅当目标仓库协议不兼容时才复用或修改项目专属上传逻辑。
### 2. 确认发布边界
上传是外部写操作。仅当用户明确要求发布、上传或提交时执行;若用户只要求查看、
诊断或构建,则停在相应阶段。
执行上传前确认:
- 目标服务和仓库来自项目配置或用户输入,不猜测生产端点。
- 认证令牌已通过环境变量或密钥系统提供。
- 目标版本、架构和产物路径能够从构建配置推导。
- 相同版本是否允许覆盖;无法确认且可能覆盖时,先询问用户。
绝不把令牌写入命令输出、文件、提交或最终回复。不要用 `set -x` 执行含凭据的脚本。
### 3. 构建包
使用项目声明的构建目标,并显式传入版本。例如项目提供 Make 目标时:
```bash
make build-deb VERSION="$RELEASE_VERSION"
```
如果构建目标会自动上传,而当前仅获构建授权,应改用其纯构建子目标。执行前检查
所需工具和环境,例如 Docker、`dpkg-deb`、编译器、SSH 访问或前端工具链。
不得擅自清理宽泛目录。若脚本包含 `rm -rf`,先解析并确认目标是明确、受限的构建目录。
### 4. 上传前检查
定位唯一目标产物;若匹配多个包,不凭文件时间猜测:
```bash
find <artifact-dir> -maxdepth 2 -type f -name '*.deb' -print
dpkg-deb --info <package.deb>
dpkg-deb --contents <package.deb>
```
至少验证:
- 文件存在、非空且 `dpkg-deb --info` 成功。
- `Package`、`Version`、`Architecture` 与本次发布一致。
- 包内容包含预期的主程序或关键文件。
- maintainer scripts 存在时权限正确,且没有明显的宿主机破坏性操作。
建议记录 SHA-256:
```bash
sha256sum <package.deb>
```
### 5. 发布
解析当前 `SKILL.md` 所在目录,以绝对路径调用随 skill 分发的脚本:
```bash
DEB_SERVER_URL="$DEB_SERVER_URL" \
DEB_TOKEN="$DEB_TOKEN" \
DEB_REPOSITORY="$DEB_REPOSITORY" \
<skill-dir>/scripts/upload_deb.sh <exact-package-path.deb>
```
不要把脚本复制进当前项目,也不要将 token 作为命令行参数。脚本默认请求
`/api/v2/upload/package`,以 multipart 字段 `package`、`token`、
`repository_name` 上传,接受 `200` 和 `201` 为成功。端点路径不同时可设置
`DEB_UPLOAD_PATH`。
调用前确认目标服务使用上述协议;不兼容时不要强行调用。传入刚刚校验过的确切路径,
不要使用宽泛 glob。项目已有 `make upload-deb` 时,检查它是否只是包装了同一协议:
如果是,直接使用 skill 脚本;若 CI 或其他人仍依赖 Make 目标,可将目标改为调用已安装
skill 的脚本,但不要提交脚本副本。
脚本支持多个确切文件路径,会汇总每个文件的结果,并在任一失败时返回非零。
### 6. 验证与汇报
发布成功不能只依据“curl 已执行”。综合检查:
- 上传命令退出码为零。
- HTTP 状态和响应体明确表示成功。
- 若仓库提供只读查询、索引或下载地址,再确认该包和版本已可见。
- 若索引更新是异步的,报告“上传已接受,索引尚待更新”,不要声称已完全可用。
最终回复给出:
- 包名、版本、架构。
- 产物路径和 SHA-256。
- 目标服务/仓库的非敏感标识。
- 构建、上传及仓库可见性各自的验证结果。
- 任何未完成项或回滚/覆盖风险。
## 修改已有发布逻辑时
- 保持项目现有变量名和调用入口,避免无关重构。
- 修复行为缺陷时增加最小静态检查或可离线运行的测试。
- 可用 `bash -n` 检查脚本语法;项目有 ShellCheck 时一并运行。
- skill 自带上传脚本是 SSOT;通用上传行为的修改应落在
`skills/deb-publisher/scripts/upload_deb.sh`,不要同步复制到业务项目。
- 不通过真实生产上传来测试脚本,除非用户明确授权并给出测试版本或测试仓库。
## 完成标准
- 仅分析:入口、调用链、配置来源和风险已被准确说明。
- 仅构建:DEB 已生成,元数据、内容和校验和通过检查,未发生上传。
- 发布:构建检查通过,服务端接受上传,且仓库可见性已验证或被准确标记为待更新。
+133
View File
@@ -0,0 +1,133 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'EOF'
Usage:
DEB_SERVER_URL=https://deb.example.com \
DEB_TOKEN=secret \
DEB_REPOSITORY=main \
upload_deb.sh FILE.deb [FILE.deb ...]
Options:
-s SERVER_URL Override DEB_SERVER_URL
-n REPOSITORY Override DEB_REPOSITORY
-p UPLOAD_PATH Override DEB_UPLOAD_PATH (default: /api/v2/upload/package)
-h Show help
The endpoint must accept multipart fields named package, token, and
repository_name. Authentication is read only from DEB_TOKEN so it is not
exposed in the process command line.
EOF
}
server_url=${DEB_SERVER_URL:-}
repository=${DEB_REPOSITORY:-}
upload_path=${DEB_UPLOAD_PATH:-/api/v2/upload/package}
token=${DEB_TOKEN:-}
while getopts ":s:n:p:h" option; do
case "$option" in
s) server_url=$OPTARG ;;
n) repository=$OPTARG ;;
p) upload_path=$OPTARG ;;
h) usage; exit 0 ;;
:) echo "Error: -$OPTARG requires a value" >&2; usage >&2; exit 2 ;;
\?) echo "Error: unknown option -$OPTARG" >&2; usage >&2; exit 2 ;;
esac
done
shift $((OPTIND - 1))
if [[ -z "$server_url" || -z "$repository" || -z "$token" || $# -eq 0 ]]; then
echo "Error: DEB_SERVER_URL, DEB_TOKEN, DEB_REPOSITORY, and at least one file are required." >&2
usage >&2
exit 2
fi
if [[ "$upload_path" != /* ]]; then
echo "Error: upload path must start with /" >&2
exit 2
fi
if ! command -v curl >/dev/null 2>&1; then
echo "Error: curl is required." >&2
exit 2
fi
server_url=${server_url%/}
success_count=0
fail_count=0
response_file=
cleanup() {
if [[ -n "$response_file" && -f "$response_file" ]]; then
rm -f -- "$response_file"
fi
}
trap cleanup EXIT
print_response() {
local file=$1
if command -v jq >/dev/null 2>&1 && jq -e . "$file" >/dev/null 2>&1; then
jq . "$file"
else
cat -- "$file"
fi
}
for package_file in "$@"; do
if [[ ! -f "$package_file" ]]; then
echo "Skip: file not found: $package_file" >&2
fail_count=$((fail_count + 1))
continue
fi
if [[ "$package_file" != *.deb ]]; then
echo "Skip: not a .deb file: $package_file" >&2
fail_count=$((fail_count + 1))
continue
fi
if [[ ! -s "$package_file" ]]; then
echo "Skip: empty file: $package_file" >&2
fail_count=$((fail_count + 1))
continue
fi
response_file=$(mktemp)
echo "Uploading $(basename -- "$package_file") to $server_url (repository $repository)..."
http_code=000
if http_code=$(curl --silent --show-error \
--output "$response_file" \
--write-out "%{http_code}" \
--request POST \
"$server_url$upload_path" \
--form "package=@${package_file};type=application/vnd.debian.binary-package" \
--form "token=${token}" \
--form "repository_name=${repository}"); then
:
else
echo "Failed (transport error): $(basename -- "$package_file")" >&2
print_response "$response_file" >&2
fail_count=$((fail_count + 1))
cleanup
response_file=
continue
fi
if [[ "$http_code" == 200 || "$http_code" == 201 ]]; then
echo "Success ($http_code): $(basename -- "$package_file")"
print_response "$response_file"
success_count=$((success_count + 1))
else
echo "Failed ($http_code): $(basename -- "$package_file")" >&2
print_response "$response_file" >&2
fail_count=$((fail_count + 1))
fi
cleanup
response_file=
done
echo "Done. Success: $success_count, Failed: $fail_count"
if ((fail_count > 0)); then
exit 1
fi
+67
View File
@@ -0,0 +1,67 @@
# skiff
`skiff` 用于创建、维护、安装和发布团队自研的 Agent Skill。Skill 的唯一来源位于
`~/.skills/skills/<name>/`,安装到各 Agent 时使用软链接。
## 什么时候使用
- 想把项目里的重复工作沉淀成一个 skill。
- 想完善、校验、转正或发布已有 skill。
- 想把自研 skill 安装到当前项目或全局 Agent。
- 想检查并修复 skill 软链接。
## 创建一个 skill
```bash
skiff create my-skill \
--idea "描述这个 skill 要解决的重复问题" \
--from-project .
```
命令会在 `~/.skills/.drafts/my-skill/` 创建:
- `SKILL.md`:给 Agent 阅读的工作流与约束。
- `README.md`:给人类阅读的用途、准备事项、示例和完成标准。
- `brief.yaml`:草稿来源信息,转正时自动移除。
完善 `SKILL.md` 和 `README.md` 后运行:
```bash
skiff check my-skill
skiff finalize my-skill
```
## 提交和发布
只提交:
```bash
skiff publish skills/my-skill -m "add my-skill"
```
提交并推送:
```bash
skiff publish skills/my-skill -m "add my-skill" --push
```
## 安装
安装到当前项目的 Codex:
```bash
skiff add my-skill -a codex -y
```
安装到全局 Codex:
```bash
skiff add my-skill -a codex -g -y
```
## 如何判断完成
- `skiff check <name>` 输出校验通过。
- 正式 skill 同时包含 `SKILL.md` 和 `README.md`。
- `skiff status` 或 `skiff doctor` 显示目标软链接正常。
+32 -13
View File
@@ -2,8 +2,9 @@
name: skiff
description: >-
创建和维护 ~/.skills 自研 skill:把项目开发中产生的想法提炼为草稿,完善并校验后发布,
或用 skiff add/remove 在项目及全局挂卸 skill。触发词:skiff、自研 skill、创建 skill、
想做一个 skill、publish skill、安装自研 skill、更新 skill 到项目。
用 skiff add/remove 在项目及全局挂卸 skill,或用 skiff init 初始化 skill 项目状态。
触发词:skiff、自研 skill、创建 skill、想做一个 skill、publish skill、安装自研 skill、
更新 skill 到项目、初始化 skill。
---
# skiff 自研 Skill 工作流
@@ -14,7 +15,7 @@ SSOT 固定在 `~/.skills/skills/<name>/`。内容通过 **symlink** 分发到
## 在项目中使用 skill
先浏览可用的 owned skill,再安装到当前项目:
先浏览可用的 builtin skill,再安装到当前项目:
```bash
skiff add --list
@@ -37,15 +38,16 @@ skiff add <name> -a cursor -a claude -a codex -y
skiff create <name> --idea "<用户原始想法>" --from-project .
```
4. 编辑 `~/.skills/.drafts/<name>/SKILL.md`,完善触发条件、不适用场景、步骤、边界与验证方法。仅在确有必要时增加 `references/`、`scripts/` 或 `assets/`。
5. 不要把项目专属路径、私有业务规则、一次性命令或密钥复制到通用 skill。
6. 运行校验并修复所有问题:
4. 编辑 `~/.skills/.drafts/<name>/SKILL.md`,完善触发条件、不适用场景、步骤、边界与验证方法。
5. 编辑同目录的 `README.md`,用面向人类的语言说明用途、准备事项、可直接复制的请求示例、Agent 会做什么以及如何判断完成。README 不应复述 Agent 内部指令。
6. 仅在确有必要时增加 `references/`、`scripts/` 或 `assets/`。不要把项目专属路径、私有业务规则、一次性命令或密钥复制到通用 skill。
7. 运行校验并修复所有问题:
```bash
skiff check <name>
```
7. 向用户展示名称、description、核心步骤和验证方式。获得确认后再转正:
8. 向用户展示名称、description、README 的人类使用方式、核心步骤和验证方式。获得确认后再转正:
```bash
skiff finalize <name>
@@ -59,11 +61,11 @@ skiff finalize <name>
1. 先记录最小证据:触发用户表达、使用的 skill 名称、实际结果、期望结果,以及能复现问题的必要项目上下文。
2. 判断归属:
- 通用工作流、触发条件或验证缺陷:回流 owned skill。
- 通用工作流、触发条件或验证缺陷:回流 builtin skill。
- 仅当前项目成立的命令、路径、业务规则:留在项目文档或项目配置,不写回通用 skill。
- CLI 安装、软链或校验行为异常:修改 `~/.skills/skiff/` 中的 CLI 和测试。
- 第三方 skill:不要复制成 owned skill 或直接改安装目录;整理证据反馈上游,除非用户明确决定维护 fork。
3. 确认真实来源。Agent 目录通常是软链,owned skill 的 SSOT 固定为:
- 第三方 skill:不要复制成 builtin skill 或直接改安装目录;整理证据反馈上游,除非用户明确决定维护 fork。
3. 确认真实来源。Agent 目录通常是软链,builtin skill 的 SSOT 固定为:
```text
~/.skills/skills/<name>/
@@ -106,7 +108,7 @@ skiff add discussion-notes -a cursor -g -y
skiff add discussion-notes -a cursor -a codex -g -y
```
registry 条目既可以指向单个 skill,也可以指向包含多个 skill 目录的
catalog source既可以指向单个 skill,也可以指向包含多个 skill 目录的
collection。安装 collection 全部内容或其中一个:
```bash
@@ -114,7 +116,11 @@ skiff add waza -a codex -g -y
skiff add waza/think -a codex -g -y
```
`skiff select` 会把 collection 展开为 `waza/think`、`waza/ui` 等候选项。
`skiff select` 会把 collection 显示为两级菜单:选择 `waza` 仓库会选中其
全部子 skill,也可以只选择 `waza/think`、`waza/ui` 中的若干项。
普通 `skiff select` 只向项目安装,并只读显示每个 Agent 的全局安装状态;
`skiff select -g` 只向全局安装。取消已勾选项不会卸载,卸载继续使用
`skiff remove`。
卸载:
@@ -130,6 +136,17 @@ skiff rm discussion-notes -g -y # rm 别名
skiff add --list
```
使用 Skill 自带模板初始化项目状态:
```bash
skiff init ack
skiff init ack --project ~/app
```
`skiff` 只负责可靠地生成项目状态文件,不复制或链接 Skill。需要分析项目并完善
ACK 配置、检查接入状态或
运行三角色闭环时,显式调用全局 `/ack` skill。
---
## 与 Vercel `npx skills` 的分工
@@ -146,6 +163,7 @@ skiff add --list
| skiff | 说明 |
|-------|------|
| `bootstrap` | 将本项目的 `skiff` skill 全局安装到所有 Agent |
| `update` | 在 `~/.skills` 执行 `git pull`,更新 skiff 自身 |
| `add <name> [-g] [-a AGENT...] [-y]` | 安装 |
| `remove <name> [-g] [-a AGENT...] [-y]` | 卸载(`rm` / `r` 别名) |
| `add --list` | 列出可用自研 skill |
@@ -155,6 +173,7 @@ skiff add --list
| `create <name> --idea TEXT [--from-project PATH]` | 创建自研 skill 草稿 |
| `check <name>` | 校验草稿或正式 skill |
| `finalize <name>` | 校验草稿并转为正式 skill |
| `init <name> [--project DIR]` | 使用 skill 模板初始化项目状态 |
---
@@ -163,4 +182,4 @@ skiff add --list
- 不要在 `project/.agents/skills/` 里直接改文件;应改 `~/.skills/skills/` 再 `publish`
- 未完成的内容保留在 `~/.skills/.drafts/`,不要直接放进正式 `skills/`
- symlink 正确时,**不需要 reinstall**;保存 SSOT 后各项目自动读到新内容
- 社区 skill 用 `npx skills add`,不要用 skiff `registry add` 除非团队要 pin 版本
- 社区 skill 用 `npx skills add`,不要用 skiff `catalog add` 除非团队要 pin 版本
+36
View File
@@ -0,0 +1,36 @@
from __future__ import annotations
import unittest
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
class AckSkillContentTests(unittest.TestCase):
def test_ack_skill_owns_resources_without_modifying_agent_instructions(self) -> None:
content = (REPO_ROOT / "skills" / "ack" / "SKILL.md").read_text(encoding="utf-8")
for expected in (
"skiff init ack --project <project-root>",
"docs/ack/project.md",
"docs/ack/tasks.yaml",
"tasks: []",
"validate_tasks.py",
"references/kickoff.md",
"不要修改项目的 `AGENTS.md`",
"当前会话担任 Coordinator",
):
self.assertIn(expected, content)
def test_ack_skill_is_explicit_only(self) -> None:
metadata = (REPO_ROOT / "skills" / "ack" / "agents" / "openai.yaml").read_text(
encoding="utf-8"
)
self.assertIn('display_name: "ACK"', metadata)
self.assertIn("allow_implicit_invocation: false", metadata)
if __name__ == "__main__":
unittest.main()
@@ -7,6 +7,10 @@ import sys
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
from skiff import catalog
from skiff.catalog import catalog_checkout_path, catalog_repo
REPO_ROOT = Path(__file__).resolve().parents[1]
@@ -23,7 +27,16 @@ def write_skill(root: Path, name: str) -> Path:
return skill
class RegistryCollectionTests(unittest.TestCase):
class CatalogCollectionTests(unittest.TestCase):
def test_load_catalog_falls_back_to_legacy_registry_file(self) -> None:
legacy = self.home / "legacy-registry.yaml"
legacy.write_text("legacy:\n repo: https://example.test/legacy.git\n")
with (
patch.object(catalog, "CATALOG_FILE", self.home / "missing-catalog.yaml"),
patch.object(catalog, "LEGACY_REGISTRY_FILE", legacy),
):
self.assertIn("legacy", catalog.load_catalog())
def setUp(self) -> None:
self.temp_dir = tempfile.TemporaryDirectory()
self.home = Path(self.temp_dir.name)
@@ -55,7 +68,7 @@ class RegistryCollectionTests(unittest.TestCase):
check=True,
capture_output=True,
)
self.skills_home.joinpath("registry.yaml").write_text(
self.skills_home.joinpath("catalog.yaml").write_text(
"test-pack:\n"
f" repo: {self.upstream}\n"
" ref: main\n"
@@ -79,6 +92,17 @@ class RegistryCollectionTests(unittest.TestCase):
check=False,
)
def test_catalog_repo_expands_home_relative_local_path(self) -> None:
with patch.dict(os.environ, {"HOME": str(self.home)}):
self.assertEqual(
catalog_repo({"repo": "~/.skills"}),
str(self.skills_home),
)
self.assertEqual(
catalog_checkout_path("skills", {"repo": "~/.skills"}),
self.skills_home.resolve(),
)
def test_add_collection_installs_every_discovered_skill(self) -> None:
result = self.run_skiff("add", "test-pack", "-g", "-a", "codex")
@@ -99,7 +123,23 @@ class RegistryCollectionTests(unittest.TestCase):
self.assertTrue((skill_dir / "second-skill").is_symlink())
self.assertTrue((skill_dir / "second-skill" / "SKILL.md").is_file())
def test_add_collection_with_registry_source_installs_all(self) -> None:
def test_add_collection_with_catalog_source_installs_all(self) -> None:
result = self.run_skiff(
"add",
"test-pack",
"--source",
"catalog",
"-g",
"-a",
"codex",
)
self.assertEqual(result.returncode, 0, result.stderr)
skill_dir = self.home / ".codex" / "skills"
self.assertTrue((skill_dir / "first-skill").is_symlink())
self.assertTrue((skill_dir / "second-skill").is_symlink())
def test_legacy_registry_source_alias_still_installs(self) -> None:
result = self.run_skiff(
"add",
"test-pack",
@@ -111,9 +151,9 @@ class RegistryCollectionTests(unittest.TestCase):
)
self.assertEqual(result.returncode, 0, result.stderr)
skill_dir = self.home / ".codex" / "skills"
self.assertTrue((skill_dir / "first-skill").is_symlink())
self.assertTrue((skill_dir / "second-skill").is_symlink())
self.assertTrue(
(self.home / ".codex" / "skills" / "first-skill").is_symlink()
)
def test_project_add_records_collection_for_sync(self) -> None:
project = self.home / "project"
@@ -139,7 +179,7 @@ class RegistryCollectionTests(unittest.TestCase):
)
self.assertEqual(added.returncode, 0, added.stderr)
self.assertIn('registry: "test-pack"', project.joinpath(".skills.yaml").read_text())
self.assertIn('source: "catalog:test-pack"', project.joinpath(".skills.yaml").read_text())
self.assertEqual(synced.returncode, 0, synced.stderr)
self.assertTrue(link.is_symlink())
self.assertTrue((link / "SKILL.md").is_file())
@@ -170,9 +210,9 @@ class RegistryCollectionTests(unittest.TestCase):
self.assertEqual(installed.returncode, 0, installed.stderr)
self.assertEqual(status.returncode, 0, status.stderr)
self.assertIn("[registry:test-pack] first-skill", status.stdout)
self.assertIn("[registry:test-pack] second-skill", status.stdout)
self.assertNotIn("[registry] test-pack\n (未 fetch)", status.stdout)
self.assertIn("[catalog:test-pack] first-skill", status.stdout)
self.assertIn("[catalog:test-pack] second-skill", status.stdout)
self.assertNotIn("[catalog] test-pack\n (未 fetch)", status.stdout)
if __name__ == "__main__":
+58
View File
@@ -1,11 +1,15 @@
from __future__ import annotations
import argparse
import os
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
from skiff import cli
REPO_ROOT = Path(__file__).resolve().parents[1]
@@ -24,6 +28,13 @@ class CreateWorkflowTests(unittest.TestCase):
"---\n\n# Skill 名称\n\n## 步骤\n\n1. 第一步\n",
encoding="utf-8",
)
template.joinpath("README.md").write_text(
"# skill-name\n\n"
"用一句话告诉使用者这个 skill 能解决什么问题。\n\n"
"给出用户可以直接说出的典型请求。\n\n"
"给出一条可以直接交给 Agent 的示例请求。\n",
encoding="utf-8",
)
project_skill = self.skills_home / "skills" / "skiff"
project_skill.mkdir()
project_skill.joinpath("SKILL.md").write_text(
@@ -32,6 +43,10 @@ class CreateWorkflowTests(unittest.TestCase):
"---\n\n# skiff\n\n## 步骤\n\n1. 维护 skill。\n",
encoding="utf-8",
)
project_skill.joinpath("README.md").write_text(
"# skiff\n\n供人类使用的 skiff 说明。\n",
encoding="utf-8",
)
def tearDown(self) -> None:
self.temp_dir.cleanup()
@@ -76,6 +91,19 @@ class CreateWorkflowTests(unittest.TestCase):
self.assertIn("claude", first.stdout)
self.assertIn("codex", first.stdout)
def test_update_pulls_skills_home(self) -> None:
with (
patch.object(cli, "SKILLS_HOME", self.skills_home),
patch.object(cli, "ensure_skills_home"),
patch.object(cli.subprocess, "run") as run,
):
cli.cmd_update(argparse.Namespace())
run.assert_called_once_with(
["git", "-C", str(self.skills_home), "pull"],
check=True,
)
def test_create_writes_draft_and_brief_without_registering_owned_skill(self) -> None:
project = self.home / "project"
project.mkdir()
@@ -92,6 +120,10 @@ class CreateWorkflowTests(unittest.TestCase):
self.assertEqual(result.returncode, 0, result.stderr)
draft = self.skills_home / ".drafts" / "migration-review"
self.assertTrue((draft / "SKILL.md").is_file())
self.assertEqual(
(draft / "README.md").read_text(encoding="utf-8").splitlines()[0],
"# migration-review",
)
brief = (draft / "brief.yaml").read_text(encoding="utf-8")
self.assertIn("idea: 检查数据库迁移", brief)
self.assertIn(f"source_project: {project.resolve()}", brief)
@@ -117,12 +149,19 @@ class CreateWorkflowTests(unittest.TestCase):
"---\n\n# 数据库迁移检查\n\n## 步骤\n\n1. 检查迁移与回滚路径。\n",
encoding="utf-8",
)
draft.joinpath("README.md").write_text(
"# migration-review\n\n"
"用于检查数据库迁移安全性。\n\n"
"## 使用示例\n\n请检查这次数据库迁移。\n",
encoding="utf-8",
)
result = self.run_skiff("finalize", "migration-review")
self.assertEqual(result.returncode, 0, result.stderr)
final = self.skills_home / "skills" / "migration-review"
self.assertTrue((final / "SKILL.md").is_file())
self.assertTrue((final / "README.md").is_file())
self.assertFalse((final / "brief.yaml").exists())
self.assertFalse(draft.exists())
@@ -135,12 +174,31 @@ class CreateWorkflowTests(unittest.TestCase):
"---\n\n# 参考检查\n\n读取 [规范](references/rules.md)。\n",
encoding="utf-8",
)
draft.joinpath("README.md").write_text(
"# reference-check\n\n用于检查本地参考资料。\n",
encoding="utf-8",
)
result = self.run_skiff("check", "reference-check")
self.assertNotEqual(result.returncode, 0)
self.assertIn("引用文件不存在", result.stderr)
def test_check_rejects_missing_readme(self) -> None:
draft = self.skills_home / ".drafts" / "missing-readme"
draft.mkdir(parents=True)
draft.joinpath("SKILL.md").write_text(
"---\nname: missing-readme\ndescription: >-\n"
" 检查 skill 是否包含人类使用说明。用于维护 skill 结构时。\n"
"---\n\n# README 检查\n\n1. 检查 README。\n",
encoding="utf-8",
)
result = self.run_skiff("check", "missing-readme")
self.assertNotEqual(result.returncode, 0)
self.assertIn("缺少 README.md", result.stderr)
def test_finalize_does_not_overwrite_existing_owned_skill(self) -> None:
created = self.run_skiff("create", "collision-check", "--idea", "检查名称冲突")
self.assertEqual(created.returncode, 0, created.stderr)
+78 -7
View File
@@ -6,7 +6,10 @@ import sys
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
from skiff.project import normalize_skill_entry
from skiff.sources import fetch_source
REPO_ROOT = Path(__file__).resolve().parents[1]
@@ -23,12 +26,24 @@ def write_skill(root: Path, name: str) -> Path:
class CustomSourceTests(unittest.TestCase):
def test_legacy_manifest_sources_normalize_to_new_model(self) -> None:
self.assertEqual(
normalize_skill_entry({"name": "ack", "source": "owned"})["source"],
"builtin",
)
self.assertEqual(
normalize_skill_entry(
{"name": "think", "source": "registry", "registry": "waza"}
)["source"],
"catalog:waza",
)
def setUp(self) -> None:
self.temp_dir = tempfile.TemporaryDirectory()
self.home = Path(self.temp_dir.name)
self.skills_home = self.home / ".skills"
(self.skills_home / "skills").mkdir(parents=True)
(self.skills_home / "registry.yaml").write_text("", encoding="utf-8")
(self.skills_home / "catalog.yaml").write_text("", encoding="utf-8")
def tearDown(self) -> None:
self.temp_dir.cleanup()
@@ -72,6 +87,36 @@ class CustomSourceTests(unittest.TestCase):
config = self.home / ".config" / "skiff" / "config.yaml"
self.assertIn("company:", config.read_text(encoding="utf-8"))
def test_local_source_can_expose_a_single_skill(self) -> None:
source = self.home / "single-source"
source.mkdir()
source.joinpath("SKILL.md").write_text(
"---\nname: solo\ndescription: Test single source.\n---\n",
encoding="utf-8",
)
added = self.run_skiff(
"source",
"add",
"solo",
"--local",
str(source),
"--skills-path",
".",
)
installed = self.run_skiff(
"add",
"solo",
"-g",
"-a",
"codex",
)
self.assertEqual(added.returncode, 0, added.stderr)
self.assertEqual(installed.returncode, 0, installed.stderr)
self.assertEqual(
(self.home / ".codex" / "skills" / "solo").resolve(),
source.resolve(),
)
def test_project_add_persists_resolved_source_in_manifest(self) -> None:
company = self.home / "company"
expected = write_skill(company / "skills", "code-review")
@@ -134,17 +179,17 @@ class CustomSourceTests(unittest.TestCase):
result = self.run_skiff("add", "code-review", "-g", "-a", "codex")
self.assertNotEqual(result.returncode, 0)
self.assertIn("owned/code-review", result.stderr)
self.assertIn("builtin/code-review", result.stderr)
self.assertIn("company/code-review", result.stderr)
def test_custom_source_namespace_is_not_shadowed_by_registry_collection(self) -> None:
def test_custom_source_namespace_is_not_shadowed_by_catalog_collection(self) -> None:
company = self.home / "company"
expected = write_skill(company / "skills", "code-review")
registry_repo = self.home / "registry-repo"
write_skill(registry_repo / "skills", "other-skill")
self.skills_home.joinpath("registry.yaml").write_text(
catalog_repo = self.home / "catalog-repo"
write_skill(catalog_repo / "skills", "other-skill")
self.skills_home.joinpath("catalog.yaml").write_text(
"company:\n"
f" repo: {registry_repo}\n"
f" repo: {catalog_repo}\n"
" ref: main\n"
" path: skills\n",
encoding="utf-8",
@@ -228,6 +273,32 @@ class CustomSourceTests(unittest.TestCase):
self.assertTrue((checkout / ".git").is_dir())
self.assertTrue((checkout / "skills" / "release-check" / "SKILL.md").is_file())
def test_git_source_clone_is_shallow(self) -> None:
checkout = self.home / "checkout"
entry = {
"repo": "https://example.test/company-skills.git",
"ref": "main",
"checkout": str(checkout),
}
with patch("skiff.sources.subprocess.run") as run:
fetch_source("company", entry)
run.assert_called_once_with(
[
"git",
"clone",
"--depth",
"1",
"--branch",
"main",
"--",
"https://example.test/company-skills.git",
str(checkout),
],
check=True,
)
if __name__ == "__main__":
unittest.main()
+1 -1
View File
@@ -36,7 +36,7 @@ class InstallScriptTests(unittest.TestCase):
link = home / relative
self.assertTrue(link.is_symlink(), relative)
self.assertEqual(link.resolve(), (REPO_ROOT / "skills" / "skiff").resolve())
self.assertIn("已安装项目 skill", result.stdout)
self.assertIn("已全局安装 builtin skiff skill", result.stdout)
if __name__ == "__main__":
+2
View File
@@ -19,6 +19,8 @@ class ProjectSkillContentTests(unittest.TestCase):
"skiff check <name>",
"~/.skills/skills/<name>/",
"第三方 skill",
"skiff init ack",
"显式调用全局 `/ack` skill",
):
self.assertIn(expected, content)
+365 -43
View File
@@ -12,19 +12,23 @@ from unittest.mock import Mock, patch
from skiff import cli
from skiff import yaml_io
from skiff.registry import external_repo_path, external_skill_path
from skiff.selector import SkillChoice, filter_choices, select_skills
from skiff.catalog import catalog_repo_path, catalog_skill_path
from skiff.selector import SkillChoice, filter_choices, fit_to_width, select_skills
REPO_ROOT = Path(__file__).resolve().parents[1]
class SelectorTests(unittest.TestCase):
def test_fit_to_width_counts_cjk_as_two_columns(self) -> None:
self.assertEqual(fit_to_width("a中文b", 4), "a中")
self.assertEqual(fit_to_width("初始化 ACK", 8), "初始化 A")
def test_manifest_entry_targets_round_trip(self) -> None:
data = {
"skills": [
{
"name": "external-one",
"name": "catalog-one",
"source": "registry",
"targets": ["codex"],
}
@@ -35,18 +39,24 @@ class SelectorTests(unittest.TestCase):
def test_filter_matches_name_kind_and_description(self) -> None:
choices = [
SkillChoice("frontend-design", "external", "创建界面"),
SkillChoice("discussion-notes", "owned", "维护讨论笔记"),
SkillChoice("frontend-design", "catalog", "创建界面"),
SkillChoice(
"discussion-notes",
"builtin",
"维护讨论笔记",
readonly_status="全局: codex",
),
]
self.assertEqual([c.name for c in filter_choices(choices, "front")], ["frontend-design"])
self.assertEqual([c.name for c in filter_choices(choices, "owned")], ["discussion-notes"])
self.assertEqual([c.name for c in filter_choices(choices, "builtin")], ["discussion-notes"])
self.assertEqual([c.name for c in filter_choices(choices, "界面")], ["frontend-design"])
self.assertEqual([c.name for c in filter_choices(choices, "codex")], ["discussion-notes"])
def test_selector_preserves_preselected_items(self) -> None:
choices = [
SkillChoice("already-there", "owned", installed=True),
SkillChoice("new-skill", "external"),
SkillChoice("already-there", "builtin", installed=True),
SkillChoice("new-skill", "catalog"),
]
screen = Mock()
screen.getmaxyx.return_value = (24, 100)
@@ -59,22 +69,130 @@ class SelectorTests(unittest.TestCase):
)
self.assertEqual(selected, {"already-there"})
rendered = [call.args[2] for call in screen.addnstr.call_args_list]
self.assertTrue(any("取消选择不卸载" in line for line in rendered))
def test_selector_renders_scope_and_readonly_global_status(self) -> None:
choices = [
SkillChoice(
"ack",
"builtin",
installed=False,
readonly_status="全局: cursor,claude,codex",
)
]
screen = Mock()
screen.getmaxyx.return_value = (24, 120)
screen.get_wch.return_value = "\n"
with patch.object(curses, "curs_set"):
selected = select_skills(
choices,
scope_label="项目 /code/app(全局状态只读)",
wrapper=lambda draw: draw(screen),
)
rendered = [call.args[2] for call in screen.addnstr.call_args_list]
self.assertEqual(selected, set())
self.assertTrue(any("安装目标: 项目 /code/app" in line for line in rendered))
title = "".join(
call.args[2]
for call in screen.addnstr.call_args_list
if call.args[0] == 3
)
self.assertIn("[ ] ack", title)
self.assertIn("全局: cursor,claude,codex", title)
self.assertNotIn("初始化", title)
def test_selector_renders_description_on_indented_second_line(self) -> None:
choices = [
SkillChoice(
"ack",
"builtin",
description="初始化、检查并运行 ACK",
readonly_status="全局: codex",
)
]
screen = Mock()
screen.getmaxyx.return_value = (24, 100)
screen.get_wch.return_value = "\n"
with patch.object(curses, "curs_set"):
select_skills(choices, wrapper=lambda draw: draw(screen))
title_calls = [
call for call in screen.addnstr.call_args_list if call.args[0] == 3
]
description_call = next(
call for call in screen.addnstr.call_args_list if call.args[0] == 4
)
self.assertEqual(
"".join(call.args[2] for call in title_calls),
"[ ] ack builtin 全局: codex",
)
self.assertEqual(description_call.args[2], " 初始化、检查并运行 ACK")
name_call = next(call for call in title_calls if call.args[2] == "ack")
self.assertTrue(name_call.args[4] & curses.A_BOLD)
self.assertTrue(description_call.args[4] & curses.A_DIM)
def test_repository_choice_toggles_all_child_skills(self) -> None:
choices = [
SkillChoice(
"waza",
"repository",
children=("waza/think", "waza/ui"),
),
SkillChoice("waza/think", "catalog:waza", indent=1),
SkillChoice("waza/ui", "catalog:waza", indent=1),
]
screen = Mock()
screen.getmaxyx.return_value = (24, 100)
screen.get_wch.side_effect = [" ", "\n"]
with patch.object(curses, "curs_set"):
selected = select_skills(choices, wrapper=lambda draw: draw(screen))
self.assertEqual(selected, {"waza/think", "waza/ui"})
def test_repository_choice_renders_partial_selection(self) -> None:
choices = [
SkillChoice(
"waza",
"repository",
children=("waza/think", "waza/ui"),
),
SkillChoice("waza/think", "catalog:waza", installed=True, indent=1),
SkillChoice("waza/ui", "catalog:waza", indent=1),
]
screen = Mock()
screen.getmaxyx.return_value = (24, 100)
screen.get_wch.return_value = "\n"
with patch.object(curses, "curs_set"):
select_skills(choices, wrapper=lambda draw: draw(screen))
first_row = "".join(
call.args[2]
for call in screen.addnstr.call_args_list
if call.args[0] == 3
)
self.assertIn("[-] waza", first_row)
def test_shared_repo_path_is_same_for_different_skill_entries(self) -> None:
first = {"repo": "https://example.test/skills.git", "ref": "main", "path": "a"}
second = {"repo": "https://example.test/skills.git", "ref": "main", "path": "b"}
self.assertEqual(external_repo_path(first), external_repo_path(second))
self.assertEqual(catalog_repo_path(first), catalog_repo_path(second))
def test_external_skill_path_rejects_checkout_escape(self) -> None:
def test_catalog_skill_path_rejects_checkout_escape(self) -> None:
entry = {
"repo": "https://example.test/skills.git",
"ref": "main",
"path": "../../outside",
}
with self.assertRaisesRegex(SystemExit, "超出外部仓库"):
external_skill_path("unsafe-skill", entry)
with self.assertRaisesRegex(SystemExit, "超出来源仓库"):
catalog_skill_path("unsafe-skill", entry)
def test_collection_discovery_ignores_symlinked_skill(self) -> None:
with tempfile.TemporaryDirectory() as temp:
@@ -92,10 +210,47 @@ class SelectorTests(unittest.TestCase):
"path": "skills",
}
with patch("skiff.registry.external_checkout_path", return_value=checkout):
from skiff.registry import discover_external_skills
with patch("skiff.catalog.catalog_checkout_path", return_value=checkout):
from skiff.catalog import discover_catalog_skills
self.assertEqual(discover_external_skills("unsafe", entry), {})
self.assertEqual(discover_catalog_skills("unsafe", entry), {})
def test_catalog_clone_is_shallow(self) -> None:
with tempfile.TemporaryDirectory() as temp:
root = Path(temp)
checkout = root / "checkout"
skill_root = checkout / "skills"
entry = {
"repo": "https://example.test/skills.git",
"ref": "main",
"path": "skills",
}
with (
patch.object(cli, "catalog_skill_path", return_value=skill_root),
patch.object(cli, "catalog_checkout_path", return_value=checkout),
patch.object(
cli,
"discover_catalog_skills",
side_effect=[{}, {"demo": skill_root / "demo"}],
),
patch.object(cli.subprocess, "run") as run,
):
cli._ensure_catalog_fetched("demo-pack", entry)
run.assert_called_once_with(
[
"git",
"clone",
"--depth",
"1",
"--branch",
"main",
"--",
"https://example.test/skills.git",
str(checkout),
],
check=True,
)
class SelectCommandTests(unittest.TestCase):
@@ -122,9 +277,9 @@ class SelectCommandTests(unittest.TestCase):
def test_project_selection_installs_new_and_records_all_selected(self) -> None:
with tempfile.TemporaryDirectory() as temp:
project = Path(temp)
external = project / "external-one"
external.mkdir()
external.joinpath("SKILL.md").write_text("---\n", encoding="utf-8")
catalog = project / "catalog-one"
catalog.mkdir()
catalog.joinpath("SKILL.md").write_text("---\n", encoding="utf-8")
args = argparse.Namespace(
agents=[["codex"]],
global_scope=False,
@@ -136,36 +291,52 @@ class SelectCommandTests(unittest.TestCase):
stdin.isatty.return_value = True
stdout.isatty.return_value = True
installed: list[str] = []
selected_choices: list[SkillChoice] = []
selected_scope: list[str] = []
def choose(
choices: list[SkillChoice],
*,
scope_label: str,
) -> set[str]:
selected_choices.extend(choices)
selected_scope.append(scope_label)
return {"builtin-one", "catalog-one"}
with (
patch.object(cli.sys, "stdin", stdin),
patch.object(cli.sys, "stdout", stdout),
patch.object(cli, "ensure_skills_home"),
patch.object(cli, "list_owned_skills", return_value=["owned-one"]),
patch.object(cli, "skill_description", return_value="owned"),
patch.object(cli, "list_builtin_skills", return_value=["builtin-one"]),
patch.object(cli, "skill_description", return_value="builtin"),
patch.object(
cli,
"load_registry",
"load_catalog",
return_value={
"external-one": {
"catalog-one": {
"repo": "https://example.test/skills.git",
"ref": "main",
"path": "external-one",
"path": "catalog-one",
}
},
),
patch.object(cli, "_registry_skill_names", return_value=["external-one"]),
patch.object(cli, "external_skill_path", return_value=external),
patch.object(cli, "_list_fully_installed_names", return_value=["owned-one"]),
patch.object(cli, "_catalog_skill_names", return_value=["catalog-one"]),
patch.object(cli, "catalog_skill_path", return_value=catalog),
patch.object(cli, "_list_fully_installed_names", return_value=["builtin-one"]),
patch.object(
cli,
"_global_installation_note",
return_value="全局: codex",
),
patch.object(
cli,
"_is_fully_installed",
side_effect=lambda name, expected, project_root, targets: name == "owned-one",
side_effect=lambda name, expected, project_root, targets: name == "builtin-one",
),
patch.object(
cli,
"select_skills",
return_value={"owned-one", "external-one"},
side_effect=choose,
),
patch.object(
cli,
@@ -177,14 +348,49 @@ class SelectCommandTests(unittest.TestCase):
manifest = (project / ".skills.yaml").read_text(encoding="utf-8")
self.assertEqual(installed, ["external-one"])
self.assertIn("owned-one", manifest)
self.assertIn('name: "external-one"', manifest)
self.assertIn("source: registry", manifest)
self.assertEqual(installed, ["catalog-one"])
self.assertIn("builtin-one", manifest)
self.assertIn("source: builtin", manifest)
self.assertIn('name: "catalog-one"', manifest)
self.assertIn('source: "catalog:catalog-one"', manifest)
self.assertIn("targets:", manifest)
self.assertIn("codex", manifest)
self.assertEqual(
[choice.readonly_status for choice in selected_choices],
["全局: codex", "全局: codex"],
)
self.assertIn("全局状态只读", selected_scope[0])
def test_select_rejects_invalid_registry_name_before_rendering(self) -> None:
def test_global_installation_note_reports_agents_and_source_conflicts(self) -> None:
with tempfile.TemporaryDirectory() as temp:
root = Path(temp)
expected = root / "expected"
other = root / "other"
expected.mkdir()
other.mkdir()
global_dirs = {
target: root / target / "skills"
for target in ("cursor", "claude", "codex")
}
for directory in global_dirs.values():
directory.mkdir(parents=True)
(global_dirs["cursor"] / "ack").symlink_to(expected, target_is_directory=True)
(global_dirs["claude"] / "ack").symlink_to(other, target_is_directory=True)
with patch.object(
cli,
"agent_skill_dir",
side_effect=lambda target, project_root=None: global_dirs[target],
):
note = cli._global_installation_note(
"ack",
expected,
["cursor", "claude", "codex"],
)
self.assertEqual(note, "全局: cursor;全局同名冲突: claude")
def test_select_rejects_invalid_catalog_name_before_rendering(self) -> None:
args = argparse.Namespace(
agents=None,
global_scope=True,
@@ -200,10 +406,10 @@ class SelectCommandTests(unittest.TestCase):
patch.object(cli.sys, "stdin", stdin),
patch.object(cli.sys, "stdout", stdout),
patch.object(cli, "ensure_skills_home"),
patch.object(cli, "list_owned_skills", return_value=[]),
patch.object(cli, "list_builtin_skills", return_value=[]),
patch.object(
cli,
"load_registry",
"load_catalog",
return_value={"../../victim": {"repo": "https://example.test/repo.git"}},
),
patch.object(cli, "select_skills") as selector,
@@ -213,7 +419,7 @@ class SelectCommandTests(unittest.TestCase):
selector.assert_not_called()
def test_select_expands_registry_collection_choices(self) -> None:
def test_select_expands_catalog_collection_choices(self) -> None:
args = argparse.Namespace(
agents=[["codex"]],
global_scope=True,
@@ -227,7 +433,7 @@ class SelectCommandTests(unittest.TestCase):
selected_choices: list[SkillChoice] = []
installed: list[tuple[str, str | None]] = []
def choose(choices: list[SkillChoice]) -> set[str]:
def choose(choices: list[SkillChoice], **_: object) -> set[str]:
selected_choices.extend(choices)
return {"waza/think"}
@@ -235,10 +441,10 @@ class SelectCommandTests(unittest.TestCase):
patch.object(cli.sys, "stdin", stdin),
patch.object(cli.sys, "stdout", stdout),
patch.object(cli, "ensure_skills_home"),
patch.object(cli, "list_owned_skills", return_value=[]),
patch.object(cli, "list_builtin_skills", return_value=[]),
patch.object(
cli,
"load_registry",
"load_catalog",
return_value={
"waza": {
"repo": "https://example.test/waza.git",
@@ -249,7 +455,7 @@ class SelectCommandTests(unittest.TestCase):
),
patch.object(
cli,
"_registry_skill_names",
"_catalog_skill_names",
return_value=["think", "ui"],
),
patch.object(cli, "_list_fully_installed_names", return_value=[]),
@@ -265,8 +471,124 @@ class SelectCommandTests(unittest.TestCase):
):
cli.cmd_select(args)
self.assertEqual([choice.name for choice in selected_choices], ["waza/think", "waza/ui"])
self.assertEqual(installed, [("think", "registry:waza")])
self.assertEqual(
[choice.name for choice in selected_choices],
["waza", "waza/think", "waza/ui"],
)
self.assertEqual(selected_choices[0].children, ("waza/think", "waza/ui"))
self.assertEqual([choice.indent for choice in selected_choices[1:]], [1, 1])
self.assertEqual(installed, [("think", "catalog:waza")])
def test_select_expands_custom_collection_choices(self) -> None:
args = argparse.Namespace(
agents=[["codex"]],
global_scope=True,
project=None,
yes=False,
)
stdin = Mock()
stdout = Mock()
stdin.isatty.return_value = True
stdout.isatty.return_value = True
selected_choices: list[SkillChoice] = []
installed: list[tuple[str, str | None]] = []
def choose(choices: list[SkillChoice], **_: object) -> set[str]:
selected_choices.extend(choices)
return {"company/review"}
with tempfile.TemporaryDirectory() as temp:
root = Path(temp)
review = root / "review"
release = root / "release"
review.mkdir()
release.mkdir()
with (
patch.object(cli.sys, "stdin", stdin),
patch.object(cli.sys, "stdout", stdout),
patch.object(cli, "ensure_skills_home"),
patch.object(cli, "list_builtin_skills", return_value=[]),
patch.object(cli, "load_catalog", return_value={}),
patch.object(
cli,
"load_sources",
return_value={"company": {"local_path": str(root)}},
),
patch.object(cli, "_ensure_source_fetched"),
patch.object(
cli,
"discover_source_skills",
return_value={"review": review, "release": release},
),
patch.object(cli, "_is_fully_installed", return_value=False),
patch.object(cli, "select_skills", side_effect=choose),
patch.object(
cli,
"_install_skill",
side_effect=lambda name, targets, project_root, **kwargs: installed.append(
(name, kwargs.get("source"))
),
),
):
cli.cmd_select(args)
self.assertEqual(
[choice.name for choice in selected_choices],
["company", "company/review", "company/release"],
)
self.assertEqual(
selected_choices[0].children,
("company/review", "company/release"),
)
self.assertEqual(installed, [("review", "company")])
def test_select_deduplicates_local_catalog_collection_from_builtin(self) -> None:
args = argparse.Namespace(
agents=[["codex"]],
global_scope=True,
project=None,
yes=False,
)
stdin = Mock()
stdout = Mock()
stdin.isatty.return_value = True
stdout.isatty.return_value = True
with tempfile.TemporaryDirectory() as temp:
skills_root = Path(temp) / "skills"
(skills_root / "ack").mkdir(parents=True)
selected_choices: list[SkillChoice] = []
def choose(choices: list[SkillChoice], **_: object) -> set[str]:
selected_choices.extend(choices)
return set()
with (
patch.object(cli.sys, "stdin", stdin),
patch.object(cli.sys, "stdout", stdout),
patch.object(cli, "SKILLS_DIR", skills_root),
patch.object(cli, "ensure_skills_home"),
patch.object(cli, "list_builtin_skills", return_value=["ack"]),
patch.object(cli, "skill_description", return_value="builtin ack"),
patch.object(
cli,
"load_catalog",
return_value={
"skills": {
"repo": "~/.skills",
"ref": "main",
"path": "skills",
}
},
),
patch.object(cli, "_catalog_skill_names", return_value=["ack"]),
patch.object(cli, "catalog_skill_path", return_value=skills_root),
patch.object(cli, "_is_fully_installed", return_value=False),
patch.object(cli, "select_skills", side_effect=choose),
):
cli.cmd_select(args)
self.assertEqual([choice.name for choice in selected_choices], ["ack"])
def test_install_rolls_back_earlier_target_when_later_target_fails(self) -> None:
with tempfile.TemporaryDirectory() as temp:
@@ -282,7 +604,7 @@ class SelectCommandTests(unittest.TestCase):
with (
patch.object(cli, "_ensure_source_fetched"),
patch.object(cli, "resolve_skill_source", return_value=(skill, "owned")),
patch.object(cli, "resolve_skill_source", return_value=(skill, "builtin")),
patch.object(
cli,
"agent_skill_dir",
+102
View File
@@ -0,0 +1,102 @@
from __future__ import annotations
import os
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
class SkillInitTests(unittest.TestCase):
def setUp(self) -> None:
self.temp_dir = tempfile.TemporaryDirectory()
self.home = Path(self.temp_dir.name)
self.skills_home = self.home / ".skills"
skill = self.skills_home / "skills" / "ack"
(skill / "templates").mkdir(parents=True)
(skill / "scripts").mkdir()
(skill / "SKILL.md").write_text("---\nname: ack\n---\n", encoding="utf-8")
(skill / "VERSION").write_text("1.2.3\n", encoding="utf-8")
(skill / "templates" / "project.template.md").write_text(
"# <project_name>\nversion=<ack_version>\npath=<overlay_file_path>\n",
encoding="utf-8",
)
(skill / "templates" / "tasks.template.yaml").write_text(
'updatedAt: "<YYYY-MM-DDTHH:mm:ss+TZ>"\n'
'ackVersion: "<接入时的 ack skill 版本>"\n'
'project:\n'
' name: "<project_name>"\n'
' repoPath: "<repo_path>"\n'
' devWorktree: "<dev_worktree>"\n',
encoding="utf-8",
)
def tearDown(self) -> None:
self.temp_dir.cleanup()
def run_skiff(self, *args: str) -> subprocess.CompletedProcess[str]:
env = os.environ.copy()
env["HOME"] = str(self.home)
env["PYTHONPATH"] = str(REPO_ROOT)
return subprocess.run(
[sys.executable, "-m", "skiff", *args],
cwd=REPO_ROOT,
env=env,
text=True,
capture_output=True,
check=False,
)
def test_init_creates_only_rendered_project_files(self) -> None:
project = self.home / "sample-app"
project.mkdir()
result = self.run_skiff("init", "ack", "--project", str(project))
self.assertEqual(result.returncode, 0, result.stderr)
target = project / "docs" / "ack"
self.assertFalse((target / "kit").exists())
self.assertFalse((target / "framework").exists())
project_content = (target / "project.md").read_text(encoding="utf-8")
tasks_content = (target / "tasks.yaml").read_text(encoding="utf-8")
self.assertIn("# sample-app", project_content)
self.assertIn("version=1.2.3", project_content)
self.assertIn(f'repoPath: "{project}"', tasks_content)
self.assertNotIn("<project_name>", tasks_content)
def test_init_refuses_to_overwrite_existing_files(self) -> None:
project = self.home / "existing-app"
target = project / "docs" / "ack"
target.mkdir(parents=True)
existing = target / "project.md"
existing.write_text("keep me", encoding="utf-8")
result = self.run_skiff("init", "ack", "--project", str(project))
self.assertNotEqual(result.returncode, 0)
self.assertIn("拒绝覆盖已有路径", result.stderr)
self.assertEqual(existing.read_text(encoding="utf-8"), "keep me")
self.assertFalse((target / "tasks.yaml").exists())
def test_init_rejects_missing_project_directory(self) -> None:
project = self.home / "missing-app"
result = self.run_skiff("init", "ack", "--project", str(project))
self.assertNotEqual(result.returncode, 0)
self.assertIn("项目目录不存在", result.stderr)
self.assertFalse(project.exists())
def test_legacy_kit_command_is_not_exposed(self) -> None:
result = self.run_skiff("kit", "init", "ack")
self.assertNotEqual(result.returncode, 0)
self.assertIn("invalid choice", result.stderr)
if __name__ == "__main__":
unittest.main()