feat: add skiff #2
+33
@@ -0,0 +1,33 @@
|
||||
# Python(skiff CLI 运行时产物)
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
|
||||
# 虚拟环境
|
||||
.venv/
|
||||
venv/
|
||||
env/
|
||||
|
||||
# 打包 / 测试(若后续扩展)
|
||||
*.egg-info/
|
||||
.eggs/
|
||||
dist/
|
||||
build/
|
||||
.pytest_cache/
|
||||
.coverage
|
||||
htmlcov/
|
||||
|
||||
# 编辑器 / IDE
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# 系统文件
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# 本地环境配置(勿提交密钥)
|
||||
.env
|
||||
.env.*
|
||||
@@ -1,20 +1,23 @@
|
||||
# Agent Skills 仓库
|
||||
|
||||
自研 Agent Skills 的单一事实来源(SSOT)。Skill 内容与本仓库内的 **skiff** CLI 一并维护。
|
||||
自研 Agent Skills 的单一事实来源(SSOT)。Skill 内容在本仓库维护;安装与管理由 [skiff](https://git.yumee.top/laily/skiff) CLI 负责。
|
||||
|
||||
|
||||
| 仓库 | 地址 | 职责 |
|
||||
| --------------- | ------------------------------------------------------------------------ | --------------- |
|
||||
| **skills**(本仓库) | [https://git.yumee.top/laily/skills](https://git.yumee.top/laily/skills) | skill 内容与规范 |
|
||||
| **skiff** | [https://git.yumee.top/laily/skiff](https://git.yumee.top/laily/skiff) | 安装、symlink、健康检查 |
|
||||
|
||||
| 组件 | 路径 | 职责 |
|
||||
|------|------|------|
|
||||
| **skills/** | 本仓库 | skill 内容与规范 |
|
||||
| **skiff/** | 本仓库 | 安装、symlink、健康检查(Python 3,无编译) |
|
||||
|
||||
---
|
||||
|
||||
## 快速开始
|
||||
|
||||
```bash
|
||||
# 1. 克隆并安装 CLI
|
||||
# 1. 克隆并关联
|
||||
git clone https://git.yumee.top/laily/skills.git ~/code/gitea/skills
|
||||
cd ~/code/gitea/skills && ./install.sh
|
||||
git clone https://git.yumee.top/laily/skiff.git ~/code/gitea/skiff
|
||||
cd ~/code/gitea/skiff && ./install.sh
|
||||
skiff setup ~/code/gitea/skills
|
||||
|
||||
# 2. 全局安装 skill
|
||||
@@ -38,15 +41,12 @@ skills/
|
||||
├── discussion-notes/ # 讨论沉淀笔记
|
||||
│ ├── SKILL.md
|
||||
│ └── reference.md
|
||||
skiff/ # CLI 源码(python3 -m skiff)
|
||||
bin/skiff # CLI 入口脚本
|
||||
install.sh # 安装到 ~/.local/bin
|
||||
registry.yaml # 外部 Git skill 来源目录
|
||||
AGENTS.md # 本文档
|
||||
```
|
||||
|
||||
**本仓库包含**:`skills/`、`skiff/`、`registry.yaml`、`AGENTS.md`
|
||||
**本仓库不包含**:各项目的 skill 启用清单(`.skills.yaml`)
|
||||
**本仓库包含**:`skills/`、`registry.yaml`、`AGENTS.md`
|
||||
**本仓库不包含**:CLI 代码、各项目的 skill 启用清单
|
||||
|
||||
---
|
||||
|
||||
@@ -54,20 +54,24 @@ AGENTS.md # 本文档
|
||||
|
||||
### 自研(Owned)
|
||||
|
||||
|
||||
| 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。
|
||||
|
||||
### 外部(External Git)
|
||||
|
||||
在 `registry.yaml` 中注册,通过 skiff 拉取安装:
|
||||
|
||||
|
||||
| Skill | 来源 |
|
||||
|-------|------|
|
||||
| superpowers | https://github.com/obra/superpowers |
|
||||
| ----------- | -------------------------------------------------------------------------- |
|
||||
| superpowers | [https://github.com/obra/superpowers](https://github.com/obra/superpowers) |
|
||||
|
||||
|
||||
```bash
|
||||
skiff fetch superpowers
|
||||
@@ -90,17 +94,16 @@ npx skills find typescript
|
||||
1. **SSOT** — 自研 skill 只存在于 `skills/<name>/`,不在 Agent 目录直接创建
|
||||
2. **项目自治** — 每个项目自己维护 `.skills.yaml`,本仓库不维护项目清单
|
||||
3. **软链优先** — 通过 symlink 映射到 Agent 目录,改 skill 即改 SSOT
|
||||
4. **一体维护** — skill 内容与 skiff CLI 同仓库,Python 3 直接运行,无需编译
|
||||
4. **仓库分离** — skill 内容与 CLI 工具分仓库,互不影响更新
|
||||
|
||||
---
|
||||
|
||||
## 架构
|
||||
|
||||
```
|
||||
skills 仓库(本仓库)
|
||||
skills 仓库(本仓库) skiff CLI
|
||||
skills/<name>/ ←── skiff install / enable
|
||||
registry.yaml ←── skiff add / fetch
|
||||
skiff/ ←── python3 -m skiff
|
||||
↑
|
||||
~/.skills(symlink)
|
||||
│
|
||||
@@ -113,20 +116,24 @@ skiff/ ←── python3 -m skiff
|
||||
|
||||
### Skill 三层分类
|
||||
|
||||
|
||||
| 层级 | 位置 | 维护方式 |
|
||||
|------|------|---------|
|
||||
| ---------------- | ---------------------------------- | ------------------------------- |
|
||||
| **Owned** | `skills/<name>/` | 本仓库 commit |
|
||||
| **External Git** | `~/.local/share/skills/externals/` | `skiff fetch` |
|
||||
| **External NPM** | `node_modules/` | `npx skills add` / `skills-npm` |
|
||||
|
||||
|
||||
### 多 Agent 路径
|
||||
|
||||
|
||||
| Agent | 全局 | 项目 |
|
||||
|-------|------|------|
|
||||
| ----------- | ------------------- | ----------------- |
|
||||
| Cursor | `~/.cursor/skills/` | `.agents/skills/` |
|
||||
| Claude Code | `~/.claude/skills/` | `.claude/skills/` |
|
||||
| Codex | `~/.codex/skills/` | `.agents/skills/` |
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 项目级启用
|
||||
@@ -147,14 +154,16 @@ targets: # 可选,默认 all
|
||||
- codex
|
||||
```
|
||||
|
||||
|
||||
| 概念 | 类比 |
|
||||
|------|------|
|
||||
| -------------- | --------------------------- |
|
||||
| skills 仓库 | npm registry |
|
||||
| `.skills.yaml` | `package.json` dependencies |
|
||||
| `skiff enable` | `npm install` |
|
||||
| `skiff sync` | `npm ci` |
|
||||
|
||||
项目级命令:
|
||||
|
||||
项目级命令(skiff 待实现):
|
||||
|
||||
```bash
|
||||
cd ~/code/my-app
|
||||
@@ -167,24 +176,31 @@ skiff sync
|
||||
|
||||
## skiff 命令
|
||||
|
||||
CLI 源码在 `skiff/`,安装:`./install.sh`(软链到 `~/.local/bin/skiff`)。
|
||||
详见 [skiff README](https://git.yumee.top/laily/skiff)。
|
||||
|
||||
### 已实现
|
||||
|
||||
### 命令一览
|
||||
|
||||
| 命令 | 说明 |
|
||||
|------|------|
|
||||
| -------------------------------------- | ------------------- |
|
||||
| `skiff setup <path>` | 关联 `~/.skills` 到本仓库 |
|
||||
| `skiff list` | 列出所有 skill |
|
||||
| `skiff status` | 安装状态总览 |
|
||||
| `skiff install <name>` | 全局安装(symlink) |
|
||||
| `skiff uninstall <name>` | 移除 symlink |
|
||||
| `skiff add / fetch / install-external` | 外部 Git skill |
|
||||
|
||||
|
||||
### 待实现
|
||||
|
||||
|
||||
| 命令 | 说明 |
|
||||
| ------------------------ | --------------------------- |
|
||||
| `skiff enable / disable` | 项目级启用/关闭 |
|
||||
| `skiff sync` | 按 `.skills.yaml` 重建 symlink |
|
||||
| `skiff create <name>` | 从 `_template/` 脚手架创建 skill |
|
||||
| `skiff doctor` | symlink 健康检查(`--fix` 自动修复) |
|
||||
| `skiff create` | 从 `_template/` 脚手架创建 skill |
|
||||
| `skiff doctor` | symlink 健康检查 |
|
||||
|
||||
`--target` 可选 `cursor`、`claude`、`codex`、`all`(默认 all)。
|
||||
|
||||
---
|
||||
|
||||
@@ -222,7 +238,7 @@ description: >-
|
||||
2. 编辑 `skills/my-skill/SKILL.md`
|
||||
3. `skiff install my-skill --target cursor` 验证
|
||||
4. 在本仓库 commit
|
||||
5. 各项目 `skiff enable my-skill`
|
||||
5. 各项目 `skiff enable my-skill`(待实现)
|
||||
|
||||
**禁止**在 `~/.cursor/skills/` 或项目 Agent 目录直接创建非 symlink 的 skill。
|
||||
|
||||
@@ -244,30 +260,35 @@ project/.agents/skills/foo/SKILL.md
|
||||
|
||||
Claude Code 对 symlink 支持不稳定:可能无法发现 skill,或写入时将 symlink 替换成普通文件。
|
||||
|
||||
|
||||
| 场景 | 建议 |
|
||||
|------|------|
|
||||
| -------------- | ----------------------------------------------------- |
|
||||
| Cursor / Codex | symlink,正常 |
|
||||
| Claude Code | symlink 单个 skill 目录,不要 symlink 整个 `~/.claude/skills/` |
|
||||
| symlink 被替换 | `skiff doctor --fix` → 重建 symlink |
|
||||
| symlink 被替换 | `skiff doctor`(待实现)→ 重建 symlink |
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 工具分工
|
||||
|
||||
|
||||
| 场景 | 工具 |
|
||||
|------|------|
|
||||
| -------------- | ---------------------------------------------------- |
|
||||
| 自研 skill 安装/管理 | **skiff** |
|
||||
| 社区 skill 安装 | **Vercel `npx skills add`** |
|
||||
| NPM 包内 skill | **skills-npm** / **skill-indexer** |
|
||||
| 搜索发现 | **npx skills find** / [skills.sh](https://skills.sh) |
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 日常速查
|
||||
|
||||
|
||||
| 我要… | 命令 | 在哪 |
|
||||
|-------|------|------|
|
||||
| 首次 setup | `git clone ... && ./install.sh && skiff setup <path>` | 任意 |
|
||||
| ---------- | --------------------------------- | ---- |
|
||||
| 首次 setup | `skiff setup ~/code/gitea/skills` | 任意 |
|
||||
| 新建 skill | 复制 `_template/` → 编辑 → commit | 本仓库 |
|
||||
| 全局启用 | `skiff install <name>` | 任意 |
|
||||
| 项目启用 | `skiff enable <name>` | 项目目录 |
|
||||
@@ -275,11 +296,14 @@ Claude Code 对 symlink 支持不稳定:可能无法发现 skill,或写入
|
||||
| 装社区 skill | `npx skills add owner/repo -g -y` | 任意 |
|
||||
| 更新外部 skill | `skiff fetch <name>` | 任意 |
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 参考
|
||||
|
||||
- [Agent Skills 开放标准](https://agentskills.io)
|
||||
- [skiff CLI](https://git.yumee.top/laily/skiff)
|
||||
- [Vercel skills CLI](https://github.com/vercel-labs/skills)
|
||||
- [skills.sh](https://skills.sh)
|
||||
- [Cursor Skills 文档](https://cursor.com/docs/context/skills)
|
||||
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
# Agent Skills
|
||||
|
||||
自研 [Agent Skills](https://agentskills.io) 的单一事实来源(SSOT)。Skill 内容与 **skiff** CLI 在本仓库一并维护。
|
||||
|
||||
## 快速开始
|
||||
|
||||
```bash
|
||||
git clone https://git.yumee.top/laily/skills.git ~/code/gitea/skills
|
||||
cd ~/code/gitea/skills
|
||||
./install.sh
|
||||
skiff setup ~/code/gitea/skills
|
||||
|
||||
skiff install declarative-openspec-loop
|
||||
skiff list
|
||||
skiff status
|
||||
```
|
||||
|
||||
## 仓库结构
|
||||
|
||||
```
|
||||
skills/ # 自研 skill(SSOT)
|
||||
skiff/ # CLI 源码(Python 3)
|
||||
bin/skiff # CLI 入口
|
||||
registry.yaml # 外部 Git skill 目录
|
||||
AGENTS.md # 详细规范与架构说明
|
||||
```
|
||||
|
||||
| 路径 | 说明 |
|
||||
|------|------|
|
||||
| [skills/](skills/) | 自研 skill,每个子目录含 `SKILL.md` |
|
||||
| [skiff/](skiff/README.md) | 安装、软链、健康检查 CLI |
|
||||
| [registry.yaml](registry.yaml) | 外部 Git skill 注册表 |
|
||||
| [AGENTS.md](AGENTS.md) | 设计原则、编写规范、架构详解 |
|
||||
|
||||
## 自研 Skill
|
||||
|
||||
| Skill | 说明 |
|
||||
|-------|------|
|
||||
| [declarative-openspec-loop](skills/declarative-openspec-loop/SKILL.md) | 声明式编程循环:用户提供校验方式,Agent 自动迭代直到通过 |
|
||||
| [discussion-notes](skills/discussion-notes/SKILL.md) | 讨论沉淀:边讨论边维护 Markdown 笔记 |
|
||||
|
||||
新建 skill:
|
||||
|
||||
```bash
|
||||
skiff create my-skill # 从 _template/ 脚手架创建
|
||||
# 编辑 skills/my-skill/SKILL.md
|
||||
skiff install my-skill # 全局安装验证
|
||||
```
|
||||
|
||||
## 安装方式
|
||||
|
||||
### 全局(用户级)
|
||||
|
||||
```bash
|
||||
skiff install <name> # 安装到 ~/.cursor/skills/ 等
|
||||
skiff install <name> --target cursor
|
||||
```
|
||||
|
||||
### 项目级
|
||||
|
||||
在项目根目录维护 `.skills.yaml`:
|
||||
|
||||
```yaml
|
||||
skills:
|
||||
- declarative-openspec-loop
|
||||
- name: superpowers
|
||||
source: registry
|
||||
ref: main
|
||||
|
||||
targets: # 可选,默认 all
|
||||
- cursor
|
||||
- claude
|
||||
- codex
|
||||
```
|
||||
|
||||
```bash
|
||||
skiff enable declarative-openspec-loop
|
||||
skiff sync
|
||||
skiff disable declarative-openspec-loop
|
||||
```
|
||||
|
||||
## 外部 Skill
|
||||
|
||||
**Git 来源**(经 skiff 管理):
|
||||
|
||||
```bash
|
||||
skiff fetch superpowers
|
||||
skiff install-external superpowers
|
||||
```
|
||||
|
||||
**社区来源**(Vercel CLI):
|
||||
|
||||
```bash
|
||||
npx skills add vercel-labs/agent-skills -g -y
|
||||
npx skills find typescript
|
||||
```
|
||||
|
||||
## 架构概览
|
||||
|
||||
```
|
||||
本仓库
|
||||
├── skills/<name>/ ←── skiff install / enable
|
||||
├── registry.yaml ←── skiff add / fetch
|
||||
└── skiff/ ←── python3 -m skiff
|
||||
↑
|
||||
~/.skills(symlink)
|
||||
│
|
||||
┌────┴────────────────┐
|
||||
▼ ▼
|
||||
~/.cursor/skills/ project/.agents/skills/
|
||||
~/.claude/skills/ project/.claude/skills/
|
||||
~/.codex/skills/ project/.skills.yaml
|
||||
```
|
||||
|
||||
## 设计原则
|
||||
|
||||
1. **SSOT** — 自研 skill 只存在于 `skills/<name>/`
|
||||
2. **项目自治** — 各项目自行维护 `.skills.yaml`
|
||||
3. **软链优先** — 通过 symlink 映射到 Agent 目录,改 skill 即改 SSOT
|
||||
4. **一体维护** — skill 与 CLI 同仓库,Python 3 直接运行,无需编译
|
||||
|
||||
## 文档
|
||||
|
||||
- [AGENTS.md](AGENTS.md) — 完整规范、多 Agent 路径、编写约定
|
||||
- [skiff/README.md](skiff/README.md) — CLI 命令参考与开发说明
|
||||
|
||||
## 参考
|
||||
|
||||
- [Agent Skills 开放标准](https://agentskills.io)
|
||||
- [Vercel skills CLI](https://github.com/vercel-labs/skills)
|
||||
- [skills.sh](https://skills.sh)
|
||||
- [Cursor Skills 文档](https://cursor.com/docs/context/skills)
|
||||
@@ -0,0 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
export PYTHONPATH="${REPO_ROOT}${PYTHONPATH:+:$PYTHONPATH}"
|
||||
exec python3 -m skiff "$@"
|
||||
Executable
+69
@@ -0,0 +1,69 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
BIN_DIR="${HOME}/.local/bin"
|
||||
PATH_MARKER="# skiff: ~/.local/bin"
|
||||
|
||||
mkdir -p "$BIN_DIR"
|
||||
ln -sf "$REPO/bin/skiff" "$BIN_DIR/skiff"
|
||||
echo "已安装 skiff -> $BIN_DIR/skiff"
|
||||
|
||||
path_already_configured() {
|
||||
local file="$1"
|
||||
[[ -f "$file" ]] && grep -qF "$PATH_MARKER" "$file"
|
||||
}
|
||||
|
||||
configure_bash() {
|
||||
local rc="${HOME}/.bashrc"
|
||||
if path_already_configured "$rc"; then
|
||||
echo "bash: $rc 已配置 PATH,跳过"
|
||||
return
|
||||
fi
|
||||
{
|
||||
echo ""
|
||||
echo "$PATH_MARKER"
|
||||
echo 'export PATH="$HOME/.local/bin:$PATH"'
|
||||
} >>"$rc"
|
||||
echo "bash: 已写入 $rc"
|
||||
}
|
||||
|
||||
configure_zsh() {
|
||||
local rc="${HOME}/.zshrc"
|
||||
if path_already_configured "$rc"; then
|
||||
echo "zsh: $rc 已配置 PATH,跳过"
|
||||
return
|
||||
fi
|
||||
{
|
||||
echo ""
|
||||
echo "$PATH_MARKER"
|
||||
echo 'export PATH="$HOME/.local/bin:$PATH"'
|
||||
} >>"$rc"
|
||||
echo "zsh: 已写入 $rc"
|
||||
}
|
||||
|
||||
configure_fish() {
|
||||
local fish_dir="${HOME}/.config/fish"
|
||||
local rc="${fish_dir}/config.fish"
|
||||
mkdir -p "$fish_dir"
|
||||
if path_already_configured "$rc"; then
|
||||
echo "fish: $rc 已配置 PATH,跳过"
|
||||
return
|
||||
fi
|
||||
{
|
||||
echo ""
|
||||
echo "$PATH_MARKER"
|
||||
echo 'fish_add_path -gm --prepend ~/.local/bin'
|
||||
} >>"$rc"
|
||||
echo "fish: 已写入 $rc"
|
||||
}
|
||||
|
||||
configure_bash
|
||||
configure_zsh
|
||||
configure_fish
|
||||
|
||||
echo ""
|
||||
echo "请重新打开终端,或执行以下命令使 PATH 生效:"
|
||||
echo " bash: source ~/.bashrc"
|
||||
echo " zsh: source ~/.zshrc"
|
||||
echo " fish: source ~/.config/fish/config.fish"
|
||||
@@ -0,0 +1 @@
|
||||
# skiff 仅使用 Python 标准库,无第三方依赖
|
||||
+149
@@ -0,0 +1,149 @@
|
||||
# skiff
|
||||
|
||||
Agent Skills 安装与管理 CLI。纯 Python 3 实现,无第三方依赖,无需编译。
|
||||
|
||||
## 安装
|
||||
|
||||
```bash
|
||||
cd /path/to/skills # 本仓库根目录
|
||||
./install.sh # 软链到 ~/.local/bin/skiff
|
||||
```
|
||||
|
||||
确保 `~/.local/bin` 在 `PATH` 中。开发时也可直接运行:
|
||||
|
||||
```bash
|
||||
PYTHONPATH=/path/to/skills python3 -m skiff <command>
|
||||
```
|
||||
|
||||
## 首次配置
|
||||
|
||||
```bash
|
||||
skiff setup ~/code/gitea/skills # 将 ~/.skills 软链到仓库
|
||||
```
|
||||
|
||||
若仓库本身就在 `~/.skills`(开发场景),`setup` 会自动识别,无需额外操作。
|
||||
|
||||
## 命令参考
|
||||
|
||||
### 仓库关联
|
||||
|
||||
| 命令 | 说明 |
|
||||
|------|------|
|
||||
| `skiff setup <path>` | 关联 `~/.skills` 到 skills 仓库 |
|
||||
|
||||
### 查看
|
||||
|
||||
| 命令 | 说明 |
|
||||
|------|------|
|
||||
| `skiff list` | 列出自研 skill 与 registry 中的外部 skill |
|
||||
| `skiff status [--target all\|cursor\|claude\|codex]` | 安装状态总览 |
|
||||
|
||||
### 全局安装(自研 skill)
|
||||
|
||||
| 命令 | 说明 |
|
||||
|------|------|
|
||||
| `skiff install <name> [--target all]` | 软链到 Agent 全局目录 |
|
||||
| `skiff uninstall <name> [--target all]` | 移除软链 |
|
||||
|
||||
全局目标路径:
|
||||
|
||||
| Agent | 路径 |
|
||||
|-------|------|
|
||||
| cursor | `~/.cursor/skills/` |
|
||||
| claude | `~/.claude/skills/` |
|
||||
| codex | `~/.codex/skills/` |
|
||||
|
||||
### 外部 Git skill
|
||||
|
||||
| 命令 | 说明 |
|
||||
|------|------|
|
||||
| `skiff add <name> <repo-url> [--ref main] [--path .]` | 写入 `registry.yaml` |
|
||||
| `skiff fetch <name>` | 克隆/更新到 `~/.local/share/skills/externals/<name>/` |
|
||||
| `skiff install-external <name> [--target all]` | 安装外部 skill 到 Agent 目录 |
|
||||
|
||||
### 项目级
|
||||
|
||||
| 命令 | 说明 |
|
||||
|------|------|
|
||||
| `skiff enable <name> [--target all] [--project <dir>]` | 写入 `.skills.yaml` 并创建项目软链 |
|
||||
| `skiff disable <name> [--target all] [--project <dir>]` | 从 manifest 移除并删除软链 |
|
||||
| `skiff sync [--target all] [--project <dir>]` | 按 `.skills.yaml` 重建软链 |
|
||||
|
||||
项目目标路径:
|
||||
|
||||
| Agent | 路径 |
|
||||
|-------|------|
|
||||
| cursor | `<project>/.agents/skills/` |
|
||||
| claude | `<project>/.claude/skills/` |
|
||||
| codex | `<project>/.agents/skills/` |
|
||||
|
||||
### 脚手架与健康检查
|
||||
|
||||
| 命令 | 说明 |
|
||||
|------|------|
|
||||
| `skiff create <name>` | 从 `skills/_template/` 创建自研 skill |
|
||||
| `skiff doctor [--target all] [--fix]` | 检查软链健康状态,`--fix` 自动修复 |
|
||||
|
||||
## 常用工作流
|
||||
|
||||
### 新建并全局启用自研 skill
|
||||
|
||||
```bash
|
||||
skiff create my-skill
|
||||
# 编辑 skills/my-skill/SKILL.md
|
||||
skiff install my-skill --target cursor
|
||||
skiff doctor --target cursor
|
||||
```
|
||||
|
||||
### 在项目中启用 skill
|
||||
|
||||
```bash
|
||||
cd ~/code/my-app
|
||||
skiff enable declarative-openspec-loop --target cursor
|
||||
skiff sync
|
||||
```
|
||||
|
||||
### 安装外部 Git skill
|
||||
|
||||
```bash
|
||||
skiff add my-ext https://github.com/org/repo --ref main
|
||||
skiff fetch my-ext
|
||||
skiff install-external my-ext
|
||||
```
|
||||
|
||||
## 源码结构
|
||||
|
||||
```
|
||||
skiff/
|
||||
├── __init__.py # 版本号
|
||||
├── __main__.py # python3 -m skiff 入口
|
||||
├── cli.py # 命令定义与调度
|
||||
├── paths.py # 路径常量与 Agent 目标
|
||||
├── skills.py # 自研 skill 发现
|
||||
├── registry.py # registry.yaml 读写
|
||||
├── project.py # .skills.yaml 管理
|
||||
├── symlinks.py # 软链创建/检查/修复
|
||||
└── yaml_io.py # 轻量 YAML 解析(无第三方依赖)
|
||||
```
|
||||
|
||||
入口脚本:[../bin/skiff](../bin/skiff)
|
||||
|
||||
## 路径约定
|
||||
|
||||
| 变量 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| `SKILLS_HOME` | `~/.skills` | skills 仓库(软链) |
|
||||
| `SKILLS_DIR` | `~/.skills/skills/` | 自研 skill 目录 |
|
||||
| `REGISTRY_FILE` | `~/.skills/registry.yaml` | 外部 skill 注册表 |
|
||||
| `EXTERNALS_DIR` | `~/.local/share/skills/externals/` | 已 fetch 的外部仓库 |
|
||||
|
||||
## 注意事项
|
||||
|
||||
- **禁止**在 `~/.cursor/skills/` 等 Agent 目录直接创建非软链的 skill
|
||||
- Claude Code 对 symlink 支持不稳定;建议对单个 skill 目录软链,不要软链整个 `~/.claude/skills/`
|
||||
- 若 Agent 将软链替换为普通目录,运行 `skiff doctor --fix` 重建
|
||||
|
||||
## 相关文档
|
||||
|
||||
- [项目 README](../README.md)
|
||||
- [AGENTS.md](../AGENTS.md)
|
||||
@@ -0,0 +1,3 @@
|
||||
"""skiff — Agent Skills 安装与管理 CLI。"""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
@@ -0,0 +1,4 @@
|
||||
from skiff.cli import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+429
@@ -0,0 +1,429 @@
|
||||
"""skiff CLI 入口。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from skiff import __version__
|
||||
from skiff.paths import (
|
||||
ALL_TARGETS,
|
||||
EXTERNALS_DIR,
|
||||
REGISTRY_FILE,
|
||||
SKILLS_DIR,
|
||||
SKILLS_HOME,
|
||||
TEMPLATE_DIR,
|
||||
agent_skill_dir,
|
||||
ensure_skills_home,
|
||||
resolve_targets,
|
||||
)
|
||||
from skiff.project import (
|
||||
add_skill_to_manifest,
|
||||
iter_manifest_skills,
|
||||
load_manifest,
|
||||
remove_skill_from_manifest,
|
||||
resolve_manifest_skill,
|
||||
save_manifest,
|
||||
)
|
||||
from skiff.registry import external_skill_path, load_registry, save_registry
|
||||
from skiff.skills import list_owned_skills, owned_skill_path, resolve_skill_source, validate_skill_name
|
||||
from skiff.symlinks import check_link, copy_template, create_link, find_repo_root, remove_link
|
||||
|
||||
|
||||
def _print(msg: str = "") -> None:
|
||||
print(msg, file=sys.stdout)
|
||||
|
||||
|
||||
def _err(msg: str) -> None:
|
||||
print(msg, file=sys.stderr)
|
||||
|
||||
|
||||
def cmd_setup(args: argparse.Namespace) -> None:
|
||||
repo = Path(args.path).resolve()
|
||||
if not (repo / "skills").is_dir():
|
||||
raise SystemExit(f"不是有效的 skills 仓库(缺少 skills/): {repo}")
|
||||
|
||||
# 开发场景:仓库本身就在 ~/.skills
|
||||
if repo == SKILLS_HOME.resolve() and SKILLS_HOME.is_dir() and not SKILLS_HOME.is_symlink():
|
||||
_print(f"skills 仓库已在 ~/.skills: {repo}")
|
||||
return
|
||||
|
||||
if SKILLS_HOME.is_symlink():
|
||||
current = SKILLS_HOME.resolve()
|
||||
if current == repo:
|
||||
_print(f"已关联: ~/.skills -> {repo}")
|
||||
return
|
||||
SKILLS_HOME.unlink()
|
||||
elif SKILLS_HOME.exists():
|
||||
raise SystemExit(f"~/.skills 已存在且不是软链: {SKILLS_HOME}")
|
||||
|
||||
SKILLS_HOME.parent.mkdir(parents=True, exist_ok=True)
|
||||
SKILLS_HOME.symlink_to(repo)
|
||||
_print(f"已关联: ~/.skills -> {repo}")
|
||||
|
||||
|
||||
def cmd_list(args: argparse.Namespace) -> None:
|
||||
ensure_skills_home()
|
||||
owned = list_owned_skills()
|
||||
registry = load_registry()
|
||||
|
||||
_print("自研 (owned):")
|
||||
for name in owned:
|
||||
_print(f" {name}")
|
||||
|
||||
_print("\n外部 (registry):")
|
||||
if not registry:
|
||||
_print(" (无)")
|
||||
else:
|
||||
for name, entry in registry.items():
|
||||
repo = entry.get("repo", "?")
|
||||
_print(f" {name} ({repo})")
|
||||
|
||||
|
||||
def _installed_links(name: str, targets: list[str], project_root: Path | None = None) -> list[tuple[str, Path, Path]]:
|
||||
skill_path, _ = resolve_skill_source(name)
|
||||
rows: list[tuple[str, Path, Path]] = []
|
||||
for target in targets:
|
||||
link = agent_skill_dir(target, project_root=project_root) / name
|
||||
rows.append((target, link, skill_path))
|
||||
return rows
|
||||
|
||||
|
||||
def cmd_status(args: argparse.Namespace) -> None:
|
||||
ensure_skills_home()
|
||||
targets = resolve_targets(args.target)
|
||||
owned = list_owned_skills()
|
||||
registry = load_registry()
|
||||
all_names = owned + [n for n in registry if n not in owned]
|
||||
|
||||
_print(f"skills 仓库: {SKILLS_HOME.resolve()}")
|
||||
_print(f"targets: {', '.join(targets)}\n")
|
||||
|
||||
for name in all_names:
|
||||
kind = "owned" if name in owned else "external"
|
||||
_print(f"[{kind}] {name}")
|
||||
try:
|
||||
rows = _installed_links(name, targets)
|
||||
except SystemExit:
|
||||
_print(" (未 fetch)")
|
||||
continue
|
||||
for target, link, expected in rows:
|
||||
status = check_link(link, expected)
|
||||
mark = "✓" if status.ok else "✗"
|
||||
detail = "" if status.ok else f" — {status.issue}"
|
||||
_print(f" {mark} {target}: {link}{detail}")
|
||||
_print("")
|
||||
|
||||
|
||||
def _install_skill(name: str, targets: list[str], project_root: Path | None = None) -> None:
|
||||
skill_path, kind = resolve_skill_source(name)
|
||||
for target in targets:
|
||||
link = agent_skill_dir(target, project_root=project_root) / name
|
||||
create_link(link, skill_path)
|
||||
scope = "项目" if project_root else "全局"
|
||||
_print(f"已安装 ({scope}/{target}): {name} -> {skill_path}")
|
||||
|
||||
|
||||
def cmd_install(args: argparse.Namespace) -> None:
|
||||
ensure_skills_home()
|
||||
validate_skill_name(args.name)
|
||||
targets = resolve_targets(args.target)
|
||||
_install_skill(args.name, targets)
|
||||
|
||||
|
||||
def cmd_uninstall(args: argparse.Namespace) -> None:
|
||||
ensure_skills_home()
|
||||
targets = resolve_targets(args.target)
|
||||
for target in targets:
|
||||
link = agent_skill_dir(target) / args.name
|
||||
if remove_link(link):
|
||||
_print(f"已移除 ({target}): {link}")
|
||||
|
||||
|
||||
def cmd_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}")
|
||||
|
||||
registry[args.name] = {
|
||||
"repo": args.repo,
|
||||
"ref": args.ref,
|
||||
"path": args.path,
|
||||
}
|
||||
save_registry(registry)
|
||||
_print(f"已添加 registry 条目: {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}")
|
||||
|
||||
entry = registry[args.name]
|
||||
repo = entry["repo"]
|
||||
ref = entry.get("ref", "main")
|
||||
dest = EXTERNALS_DIR / args.name
|
||||
|
||||
EXTERNALS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if dest.exists():
|
||||
_print(f"更新: {dest}")
|
||||
subprocess.run(["git", "-C", str(dest), "fetch", "--all", "--tags"], check=True)
|
||||
subprocess.run(["git", "-C", str(dest), "checkout", ref], check=True)
|
||||
subprocess.run(["git", "-C", str(dest), "pull", "--ff-only"], check=True)
|
||||
else:
|
||||
_print(f"克隆: {repo} -> {dest}")
|
||||
subprocess.run(
|
||||
["git", "clone", "--branch", ref, "--", repo, str(dest)],
|
||||
check=True,
|
||||
)
|
||||
|
||||
|
||||
def cmd_install_external(args: argparse.Namespace) -> None:
|
||||
ensure_skills_home()
|
||||
registry = load_registry()
|
||||
if args.name not in registry:
|
||||
raise SystemExit(f"registry 中不存在: {args.name}")
|
||||
|
||||
path = external_skill_path(args.name, registry[args.name])
|
||||
if not path.exists():
|
||||
raise SystemExit(f"请先 fetch: skiff fetch {args.name}")
|
||||
|
||||
targets = resolve_targets(args.target)
|
||||
for target in targets:
|
||||
link = agent_skill_dir(target) / args.name
|
||||
create_link(link, path)
|
||||
_print(f"已安装外部 ({target}): {args.name} -> {path}")
|
||||
|
||||
|
||||
def _project_root(args: argparse.Namespace) -> Path:
|
||||
if args.project:
|
||||
root = Path(args.project).resolve()
|
||||
else:
|
||||
root = find_repo_root() or Path.cwd()
|
||||
return root
|
||||
|
||||
|
||||
def cmd_enable(args: argparse.Namespace) -> None:
|
||||
ensure_skills_home()
|
||||
validate_skill_name(args.name)
|
||||
root = _project_root(args)
|
||||
manifest_path = root / ".skills.yaml"
|
||||
|
||||
# 判断来源
|
||||
registry = load_registry()
|
||||
if args.name in registry:
|
||||
source = "registry"
|
||||
extra = {"source": "registry", "ref": registry[args.name].get("ref", "main")}
|
||||
add_skill_to_manifest(manifest_path, args.name, source="registry", extra=extra)
|
||||
else:
|
||||
owned_skill_path(args.name) # 验证存在
|
||||
add_skill_to_manifest(manifest_path, args.name, source="owned")
|
||||
|
||||
targets = resolve_targets(args.target)
|
||||
_, 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(args.name, targets, project_root=root)
|
||||
_print(f"已启用项目 skill: {args.name} @ {root}")
|
||||
|
||||
|
||||
def cmd_disable(args: argparse.Namespace) -> None:
|
||||
root = _project_root(args)
|
||||
manifest_path = root / ".skills.yaml"
|
||||
if not remove_skill_from_manifest(manifest_path, args.name):
|
||||
_print(f"manifest 中不存在: {args.name}")
|
||||
return
|
||||
|
||||
targets = resolve_targets(args.target)
|
||||
for target in targets:
|
||||
link = agent_skill_dir(target, project_root=root) / args.name
|
||||
if remove_link(link):
|
||||
_print(f"已禁用 ({target}): {link}")
|
||||
|
||||
|
||||
def cmd_sync(args: argparse.Namespace) -> None:
|
||||
ensure_skills_home()
|
||||
root = _project_root(args)
|
||||
manifest_path = root / ".skills.yaml"
|
||||
if not manifest_path.is_file():
|
||||
raise SystemExit(f"未找到 {manifest_path}")
|
||||
|
||||
_, data = load_manifest(manifest_path)
|
||||
targets = resolve_targets(args.target)
|
||||
manifest_targets = data.get("targets")
|
||||
if manifest_targets:
|
||||
targets = [t for t in targets if t in manifest_targets]
|
||||
|
||||
for entry in iter_manifest_skills(data):
|
||||
name = entry["name"]
|
||||
skill_path, _ = resolve_manifest_skill(entry)
|
||||
for target in targets:
|
||||
link = agent_skill_dir(target, project_root=root) / name
|
||||
create_link(link, skill_path)
|
||||
_print(f"已同步: {link} -> {skill_path}")
|
||||
|
||||
|
||||
def cmd_create(args: argparse.Namespace) -> None:
|
||||
ensure_skills_home()
|
||||
validate_skill_name(args.name)
|
||||
if not TEMPLATE_DIR.is_dir():
|
||||
raise SystemExit(f"模板目录不存在: {TEMPLATE_DIR}")
|
||||
|
||||
dst = SKILLS_DIR / args.name
|
||||
copy_template(TEMPLATE_DIR, dst)
|
||||
|
||||
skill_md = dst / "SKILL.md"
|
||||
content = skill_md.read_text(encoding="utf-8")
|
||||
content = re.sub(
|
||||
r"(^name:\s*)skill-name\s*$",
|
||||
rf"\g<1>{args.name}",
|
||||
content,
|
||||
count=1,
|
||||
flags=re.MULTILINE,
|
||||
)
|
||||
skill_md.write_text(content, encoding="utf-8")
|
||||
_print(f"已创建 skill: {dst}")
|
||||
_print(f"下一步: 编辑 {skill_md},然后 skiff install {args.name}")
|
||||
|
||||
|
||||
def cmd_doctor(args: argparse.Namespace) -> None:
|
||||
ensure_skills_home()
|
||||
targets = resolve_targets(args.target)
|
||||
issues = 0
|
||||
|
||||
_print(f"skills 仓库: {SKILLS_HOME.resolve()}")
|
||||
if SKILLS_HOME.is_symlink():
|
||||
if not SKILLS_HOME.resolve().is_dir():
|
||||
_err(f"✗ ~/.skills 指向无效路径: {SKILLS_HOME.resolve()}")
|
||||
issues += 1
|
||||
else:
|
||||
_print("✓ ~/.skills 软链正常")
|
||||
elif SKILLS_HOME.is_dir() and (SKILLS_HOME / "skills").is_dir():
|
||||
_print("✓ ~/.skills 为本地仓库目录")
|
||||
else:
|
||||
_err("✗ ~/.skills 未正确配置")
|
||||
issues += 1
|
||||
|
||||
for name in list_owned_skills():
|
||||
for target, link, expected in _installed_links(name, targets):
|
||||
status = check_link(link, expected)
|
||||
if status.ok:
|
||||
continue
|
||||
_err(f"✗ [{name}/{target}] {status.issue}: {link}")
|
||||
issues += 1
|
||||
if args.fix:
|
||||
try:
|
||||
create_link(link, expected)
|
||||
_print(f" 已修复: {link}")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
_err(f" 修复失败: {exc}")
|
||||
|
||||
registry = load_registry()
|
||||
for name in registry:
|
||||
ext = external_skill_path(name, registry[name])
|
||||
if not ext.exists():
|
||||
_err(f"✗ 外部 skill 未 fetch: {name}")
|
||||
issues += 1
|
||||
|
||||
if issues == 0:
|
||||
_print("\n全部正常")
|
||||
else:
|
||||
_print(f"\n发现 {issues} 个问题")
|
||||
if not args.fix:
|
||||
_print("提示: 使用 skiff doctor --fix 尝试自动修复软链")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="skiff",
|
||||
description="Agent Skills 安装与管理 CLI",
|
||||
)
|
||||
parser.add_argument("--version", action="version", version=f"skiff {__version__}")
|
||||
|
||||
sub = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
p_setup = sub.add_parser("setup", help="关联 ~/.skills 到本仓库")
|
||||
p_setup.add_argument("path", help="skills 仓库路径")
|
||||
p_setup.set_defaults(func=cmd_setup)
|
||||
|
||||
p_list = sub.add_parser("list", help="列出所有 skill")
|
||||
p_list.set_defaults(func=cmd_list)
|
||||
|
||||
p_status = sub.add_parser("status", help="安装状态总览")
|
||||
p_status.add_argument("--target", choices=[*ALL_TARGETS, "all"], default="all")
|
||||
p_status.set_defaults(func=cmd_status)
|
||||
|
||||
p_install = sub.add_parser("install", help="全局安装 skill(软链)")
|
||||
p_install.add_argument("name", help="skill 名称")
|
||||
p_install.add_argument("--target", choices=[*ALL_TARGETS, "all"], default="all")
|
||||
p_install.set_defaults(func=cmd_install)
|
||||
|
||||
p_uninstall = sub.add_parser("uninstall", help="移除全局软链")
|
||||
p_uninstall.add_argument("name", help="skill 名称")
|
||||
p_uninstall.add_argument("--target", choices=[*ALL_TARGETS, "all"], default="all")
|
||||
p_uninstall.set_defaults(func=cmd_uninstall)
|
||||
|
||||
p_add = sub.add_parser("add", help="添加外部 skill 到 registry")
|
||||
p_add.add_argument("name", help="registry 名称")
|
||||
p_add.add_argument("repo", help="Git 仓库 URL")
|
||||
p_add.add_argument("--ref", default="main", help="分支或 tag(默认 main)")
|
||||
p_add.add_argument("--path", default=".", help="仓库内子路径(默认 .)")
|
||||
p_add.set_defaults(func=cmd_add)
|
||||
|
||||
p_fetch = sub.add_parser("fetch", help="拉取外部 Git skill")
|
||||
p_fetch.add_argument("name", help="registry 名称")
|
||||
p_fetch.set_defaults(func=cmd_fetch)
|
||||
|
||||
p_inst_ext = sub.add_parser("install-external", help="安装外部 skill 到 Agent 目录")
|
||||
p_inst_ext.add_argument("name", help="registry 名称")
|
||||
p_inst_ext.add_argument("--target", choices=[*ALL_TARGETS, "all"], default="all")
|
||||
p_inst_ext.set_defaults(func=cmd_install_external)
|
||||
|
||||
p_enable = sub.add_parser("enable", help="项目级启用 skill")
|
||||
p_enable.add_argument("name", help="skill 名称")
|
||||
p_enable.add_argument("--target", choices=[*ALL_TARGETS, "all"], default="all")
|
||||
p_enable.add_argument("--project", help="项目根目录(默认自动检测)")
|
||||
p_enable.set_defaults(func=cmd_enable)
|
||||
|
||||
p_disable = sub.add_parser("disable", help="项目级禁用 skill")
|
||||
p_disable.add_argument("name", help="skill 名称")
|
||||
p_disable.add_argument("--target", choices=[*ALL_TARGETS, "all"], default="all")
|
||||
p_disable.add_argument("--project", help="项目根目录(默认自动检测)")
|
||||
p_disable.set_defaults(func=cmd_disable)
|
||||
|
||||
p_sync = sub.add_parser("sync", help="按 .skills.yaml 重建项目软链")
|
||||
p_sync.add_argument("--target", choices=[*ALL_TARGETS, "all"], default="all")
|
||||
p_sync.add_argument("--project", help="项目根目录(默认自动检测)")
|
||||
p_sync.set_defaults(func=cmd_sync)
|
||||
|
||||
p_create = sub.add_parser("create", help="从 _template 创建自研 skill")
|
||||
p_create.add_argument("name", help="skill 名称")
|
||||
p_create.set_defaults(func=cmd_create)
|
||||
|
||||
p_doctor = sub.add_parser("doctor", help="软链健康检查")
|
||||
p_doctor.add_argument("--target", choices=[*ALL_TARGETS, "all"], default="all")
|
||||
p_doctor.add_argument("--fix", action="store_true", help="自动修复可修复的软链")
|
||||
p_doctor.set_defaults(func=cmd_doctor)
|
||||
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> None:
|
||||
parser = build_parser()
|
||||
args = parser.parse_args(argv)
|
||||
args.func(args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,48 @@
|
||||
"""路径与 Agent 目标定义。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
HOME = Path.home()
|
||||
SKILLS_HOME = HOME / ".skills"
|
||||
SKILLS_DIR = SKILLS_HOME / "skills"
|
||||
TEMPLATE_DIR = SKILLS_DIR / "_template"
|
||||
REGISTRY_FILE = SKILLS_HOME / "registry.yaml"
|
||||
EXTERNALS_DIR = HOME / ".local" / "share" / "skills" / "externals"
|
||||
PROJECT_MANIFEST = ".skills.yaml"
|
||||
|
||||
AGENT_GLOBAL: dict[str, Path] = {
|
||||
"cursor": HOME / ".cursor" / "skills",
|
||||
"claude": HOME / ".claude" / "skills",
|
||||
"codex": HOME / ".codex" / "skills",
|
||||
}
|
||||
|
||||
AGENT_PROJECT: dict[str, str] = {
|
||||
"cursor": ".agents/skills",
|
||||
"claude": ".claude/skills",
|
||||
"codex": ".agents/skills",
|
||||
}
|
||||
|
||||
ALL_TARGETS = ("cursor", "claude", "codex")
|
||||
|
||||
|
||||
def resolve_targets(target: str | None) -> list[str]:
|
||||
if target is None or target == "all":
|
||||
return list(ALL_TARGETS)
|
||||
if target not in ALL_TARGETS:
|
||||
raise ValueError(f"未知 target: {target!r},可选: {', '.join(ALL_TARGETS)}, all")
|
||||
return [target]
|
||||
|
||||
|
||||
def agent_skill_dir(target: str, *, project_root: Path | None = None) -> Path:
|
||||
if project_root is None:
|
||||
return AGENT_GLOBAL[target]
|
||||
return project_root / AGENT_PROJECT[target]
|
||||
|
||||
|
||||
def ensure_skills_home() -> None:
|
||||
if not SKILLS_HOME.is_dir():
|
||||
raise SystemExit(
|
||||
f"~/.skills 未配置。请先运行: skiff setup <skills-repo-path>"
|
||||
)
|
||||
@@ -0,0 +1,101 @@
|
||||
"""项目级 .skills.yaml 管理。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from skiff import yaml_io
|
||||
from skiff.paths import PROJECT_MANIFEST
|
||||
from skiff.skills import resolve_skill_source
|
||||
|
||||
|
||||
def load_manifest(path: Path | None = None) -> tuple[Path, dict[str, Any]]:
|
||||
path = path or Path.cwd() / PROJECT_MANIFEST
|
||||
if not path.is_file():
|
||||
return path, {"skills": []}
|
||||
data = yaml_io.safe_load(path.read_text(encoding="utf-8")) or {}
|
||||
if not isinstance(data, dict):
|
||||
raise SystemExit(f".skills.yaml 格式错误: {path}")
|
||||
if "skills" not in data:
|
||||
data["skills"] = []
|
||||
return path, data
|
||||
|
||||
|
||||
def save_manifest(path: Path, data: dict[str, Any]) -> None:
|
||||
path.write_text(
|
||||
yaml_io.safe_dump(data, allow_unicode=True, sort_keys=False),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def normalize_skill_entry(entry: str | dict[str, Any]) -> dict[str, Any]:
|
||||
if isinstance(entry, str):
|
||||
return {"name": entry, "source": "owned"}
|
||||
name = entry.get("name")
|
||||
if not name:
|
||||
raise SystemExit(f".skills.yaml 条目缺少 name: {entry}")
|
||||
source = entry.get("source", "owned")
|
||||
return {"name": name, "source": source, **{k: v for k, v in entry.items() if k not in ("name", "source")}}
|
||||
|
||||
|
||||
def manifest_skill_names(data: dict[str, Any]) -> list[str]:
|
||||
return [normalize_skill_entry(e)["name"] for e in data.get("skills", [])]
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
def add_skill_to_manifest(
|
||||
manifest_path: Path,
|
||||
name: str,
|
||||
*,
|
||||
source: str = "owned",
|
||||
extra: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
path = manifest_path
|
||||
if path.is_dir():
|
||||
path = path / PROJECT_MANIFEST
|
||||
|
||||
file_path, data = load_manifest(path) if path.is_file() else (path, {"skills": []})
|
||||
if not path.is_file():
|
||||
file_path = path
|
||||
|
||||
entries = [normalize_skill_entry(e) for e in data.get("skills", [])]
|
||||
if any(e["name"] == name for e in entries):
|
||||
return
|
||||
|
||||
item: dict[str, Any] = {"name": name, "source": source}
|
||||
if extra:
|
||||
item.update(extra)
|
||||
entries.append(item)
|
||||
|
||||
data["skills"] = [_entry_to_yaml(e) for e in entries]
|
||||
save_manifest(file_path, data)
|
||||
|
||||
|
||||
def remove_skill_from_manifest(manifest_path: Path, name: str) -> bool:
|
||||
path = manifest_path
|
||||
if not path.is_file():
|
||||
path = path / PROJECT_MANIFEST
|
||||
file_path, data = load_manifest(path)
|
||||
original = data.get("skills", [])
|
||||
kept = [e for e in original if normalize_skill_entry(e)["name"] != name]
|
||||
if len(kept) == len(original):
|
||||
return False
|
||||
data["skills"] = kept
|
||||
save_manifest(file_path, data)
|
||||
return True
|
||||
|
||||
|
||||
def iter_manifest_skills(data: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
return [normalize_skill_entry(e) for e in data.get("skills", [])]
|
||||
|
||||
|
||||
def resolve_manifest_skill(entry: dict[str, Any]) -> tuple[Path, str]:
|
||||
name = entry["name"]
|
||||
source = entry.get("source", "owned")
|
||||
return resolve_skill_source(name, source=source)
|
||||
@@ -0,0 +1,33 @@
|
||||
"""registry.yaml 读写。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from skiff import yaml_io
|
||||
from skiff.paths import REGISTRY_FILE
|
||||
|
||||
|
||||
def load_registry(path: Path | None = None) -> dict[str, dict[str, Any]]:
|
||||
path = path or 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}")
|
||||
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
|
||||
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_skill_path(name: str, entry: dict[str, Any] | None = None) -> Path:
|
||||
from skiff.paths import EXTERNALS_DIR
|
||||
|
||||
entry = entry or load_registry().get(name, {})
|
||||
subpath = entry.get("path", ".") or "."
|
||||
return (EXTERNALS_DIR / name / subpath).resolve()
|
||||
@@ -0,0 +1,64 @@
|
||||
"""自研 skill 发现与解析。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from skiff.paths import SKILLS_DIR, TEMPLATE_DIR, ensure_skills_home
|
||||
from skiff.registry import external_skill_path, load_registry
|
||||
|
||||
|
||||
def list_owned_skills() -> list[str]:
|
||||
ensure_skills_home()
|
||||
if not SKILLS_DIR.is_dir():
|
||||
return []
|
||||
names: list[str] = []
|
||||
for entry in sorted(SKILLS_DIR.iterdir()):
|
||||
if not entry.is_dir():
|
||||
continue
|
||||
if entry.name.startswith("_"):
|
||||
continue
|
||||
if (entry / "SKILL.md").is_file():
|
||||
names.append(entry.name)
|
||||
return names
|
||||
|
||||
|
||||
def owned_skill_path(name: str) -> Path:
|
||||
path = SKILLS_DIR / name
|
||||
if not (path / "SKILL.md").is_file():
|
||||
raise SystemExit(f"自研 skill 不存在: {name}")
|
||||
return path
|
||||
|
||||
|
||||
def resolve_skill_source(name: str, *, source: str | None = None) -> tuple[Path, str]:
|
||||
"""返回 (skill_path, kind),kind 为 owned 或 external。"""
|
||||
ensure_skills_home()
|
||||
|
||||
owned = SKILLS_DIR / name
|
||||
if source in (None, "owned") and (owned / "SKILL.md").is_file():
|
||||
return owned, "owned"
|
||||
|
||||
registry = load_registry()
|
||||
if source in (None, "registry") and name in registry:
|
||||
path = external_skill_path(name, registry[name])
|
||||
if not path.exists():
|
||||
raise SystemExit(
|
||||
f"外部 skill {name!r} 尚未 fetch。请先运行: skiff fetch {name}"
|
||||
)
|
||||
return path, "external"
|
||||
|
||||
if (owned / "SKILL.md").is_file():
|
||||
return owned, "owned"
|
||||
|
||||
raise SystemExit(f"找不到 skill: {name}")
|
||||
|
||||
|
||||
def validate_skill_name(name: str) -> None:
|
||||
import re
|
||||
|
||||
if not re.fullmatch(r"[a-z0-9]+(?:-[a-z0-9]+)*", name):
|
||||
raise SystemExit(
|
||||
f"skill 名称无效: {name!r}(小写 + 连字符,如 security-review)"
|
||||
)
|
||||
if name == "_template":
|
||||
raise SystemExit("不能使用保留名 _template")
|
||||
@@ -0,0 +1,73 @@
|
||||
"""软链创建、移除与健康检查。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@dataclass
|
||||
class LinkStatus:
|
||||
link: Path
|
||||
expected: Path
|
||||
ok: bool
|
||||
issue: str | None = None
|
||||
|
||||
|
||||
def create_link(link: Path, target: Path) -> None:
|
||||
target = target.resolve()
|
||||
if not target.exists():
|
||||
raise FileNotFoundError(f"目标不存在: {target}")
|
||||
|
||||
link.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if link.is_symlink():
|
||||
current = link.resolve()
|
||||
if current == target:
|
||||
return
|
||||
link.unlink()
|
||||
elif link.exists():
|
||||
raise FileExistsError(f"已存在非软链路径: {link}")
|
||||
|
||||
link.symlink_to(target, target_is_directory=target.is_dir())
|
||||
|
||||
|
||||
def remove_link(link: Path) -> bool:
|
||||
if link.is_symlink():
|
||||
link.unlink()
|
||||
return True
|
||||
if link.exists():
|
||||
raise FileExistsError(f"不是软链,未删除: {link}")
|
||||
return False
|
||||
|
||||
|
||||
def check_link(link: Path, expected: Path) -> LinkStatus:
|
||||
expected = expected.resolve()
|
||||
if not link.exists() and not link.is_symlink():
|
||||
return LinkStatus(link, expected, False, "缺失")
|
||||
if not link.is_symlink():
|
||||
return LinkStatus(link, expected, False, "非软链(可能被 Agent 替换为普通目录)")
|
||||
actual = link.resolve()
|
||||
if actual != expected:
|
||||
return LinkStatus(link, expected, False, f"指向错误: {actual}")
|
||||
if not actual.exists():
|
||||
return LinkStatus(link, expected, False, "目标不存在")
|
||||
return LinkStatus(link, expected, True)
|
||||
|
||||
|
||||
def copy_template(src: Path, dst: Path) -> None:
|
||||
if dst.exists():
|
||||
raise FileExistsError(f"已存在: {dst}")
|
||||
shutil.copytree(src, dst)
|
||||
|
||||
|
||||
def find_repo_root(start: Path | None = None) -> Path | None:
|
||||
start = (start or Path.cwd()).resolve()
|
||||
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():
|
||||
return directory
|
||||
return None
|
||||
@@ -0,0 +1,260 @@
|
||||
"""轻量 YAML 读写(覆盖 skiff 使用的子集,无第三方依赖)。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
|
||||
def safe_load(text: str) -> Any:
|
||||
if not text or not text.strip():
|
||||
return None
|
||||
lines = text.splitlines()
|
||||
# 跳过纯注释/空行,定位首个有效块
|
||||
start = 0
|
||||
while start < len(lines):
|
||||
line = _strip_comment(lines[start])
|
||||
if line.strip():
|
||||
break
|
||||
start += 1
|
||||
if start >= len(lines):
|
||||
return None
|
||||
result, _ = _parse_block(lines, start, _indent_of(_strip_comment(lines[start])))
|
||||
return result
|
||||
|
||||
|
||||
def safe_dump(data: Any, *, allow_unicode: bool = True, sort_keys: bool = False) -> str:
|
||||
del allow_unicode, sort_keys
|
||||
return _dump(data).rstrip() + "\n"
|
||||
|
||||
|
||||
def _indent_of(line: str) -> int:
|
||||
return len(line) - len(line.lstrip(" "))
|
||||
|
||||
|
||||
def _strip_comment(line: str) -> str:
|
||||
if "#" in line:
|
||||
in_single = False
|
||||
in_double = False
|
||||
for i, ch in enumerate(line):
|
||||
if ch == "'" and not in_double:
|
||||
in_single = not in_single
|
||||
elif ch == '"' and not in_single:
|
||||
in_double = not in_double
|
||||
elif ch == "#" and not in_single and not in_double:
|
||||
return line[:i].rstrip()
|
||||
return line.rstrip()
|
||||
|
||||
|
||||
def _parse_scalar(raw: str) -> Any:
|
||||
raw = raw.strip()
|
||||
if not raw:
|
||||
return ""
|
||||
if (raw.startswith('"') and raw.endswith('"')) or (raw.startswith("'") and raw.endswith("'")):
|
||||
return raw[1:-1]
|
||||
lower = raw.lower()
|
||||
if lower in ("null", "~"):
|
||||
return None
|
||||
if lower == "true":
|
||||
return True
|
||||
if lower == "false":
|
||||
return False
|
||||
if raw.isdigit() or (raw.startswith("-") and raw[1:].isdigit()):
|
||||
return int(raw)
|
||||
return raw
|
||||
|
||||
|
||||
def _parse_block(lines: list[str], start: int, base_indent: int) -> tuple[Any, int]:
|
||||
if start >= len(lines):
|
||||
return None, start
|
||||
|
||||
line = _strip_comment(lines[start])
|
||||
if not line.strip():
|
||||
return _parse_block(lines, start + 1, base_indent)
|
||||
|
||||
stripped = line.lstrip(" ")
|
||||
indent = _indent_of(line)
|
||||
|
||||
if stripped.startswith("- "):
|
||||
return _parse_list(lines, start, indent)
|
||||
|
||||
if ":" in stripped:
|
||||
return _parse_mapping(lines, start, indent)
|
||||
|
||||
raise ValueError(f"无法解析 YAML 行: {line}")
|
||||
|
||||
|
||||
def _parse_list(lines: list[str], start: int, list_indent: int) -> tuple[list[Any], int]:
|
||||
items: list[Any] = []
|
||||
i = start
|
||||
while i < len(lines):
|
||||
line = _strip_comment(lines[i])
|
||||
if not line.strip():
|
||||
i += 1
|
||||
continue
|
||||
if _indent_of(line) < list_indent:
|
||||
break
|
||||
if _indent_of(line) > list_indent or not line.lstrip().startswith("- "):
|
||||
break
|
||||
|
||||
content = line.lstrip()[2:].strip()
|
||||
if not content:
|
||||
i += 1
|
||||
continue
|
||||
|
||||
if ":" in content and not content.startswith(("http://", "https://")):
|
||||
key, rest = content.split(":", 1)
|
||||
key = key.strip()
|
||||
rest = rest.strip()
|
||||
if rest:
|
||||
item = {key: _parse_scalar(rest)}
|
||||
i += 1
|
||||
else:
|
||||
nested, i = _parse_mapping(lines, i, list_indent + 2)
|
||||
item = {key: nested}
|
||||
while i < len(lines):
|
||||
nxt = _strip_comment(lines[i])
|
||||
if not nxt.strip():
|
||||
i += 1
|
||||
continue
|
||||
if _indent_of(nxt) <= list_indent:
|
||||
break
|
||||
if not nxt.lstrip().startswith("- "):
|
||||
extra, i = _parse_mapping(lines, i, list_indent + 2)
|
||||
if isinstance(item, dict):
|
||||
item.update(extra)
|
||||
break
|
||||
i += 1
|
||||
items.append(item)
|
||||
continue
|
||||
|
||||
items.append(_parse_scalar(content))
|
||||
i += 1
|
||||
|
||||
return items, i
|
||||
|
||||
|
||||
def _parse_mapping(lines: list[str], start: int, map_indent: int) -> tuple[dict[str, Any], int]:
|
||||
result: dict[str, Any] = {}
|
||||
i = start
|
||||
while i < len(lines):
|
||||
line = _strip_comment(lines[i])
|
||||
if not line.strip():
|
||||
i += 1
|
||||
continue
|
||||
indent = _indent_of(line)
|
||||
if indent < map_indent:
|
||||
break
|
||||
if indent > map_indent:
|
||||
raise ValueError(f"缩进不一致: {line}")
|
||||
|
||||
stripped = line.lstrip()
|
||||
if stripped.startswith("- "):
|
||||
break
|
||||
|
||||
key, rest = stripped.split(":", 1)
|
||||
key = key.strip()
|
||||
rest = rest.strip()
|
||||
i += 1
|
||||
|
||||
if rest:
|
||||
result[key] = _parse_scalar(rest)
|
||||
continue
|
||||
|
||||
if i >= len(lines):
|
||||
result[key] = None
|
||||
break
|
||||
|
||||
peek = _strip_comment(lines[i])
|
||||
while peek == "" and i < len(lines):
|
||||
i += 1
|
||||
peek = _strip_comment(lines[i]) if i < len(lines) else ""
|
||||
|
||||
if i >= len(lines):
|
||||
result[key] = None
|
||||
break
|
||||
|
||||
child_indent = _indent_of(peek)
|
||||
if child_indent <= map_indent:
|
||||
result[key] = None
|
||||
continue
|
||||
|
||||
if peek.lstrip().startswith("- "):
|
||||
value, i = _parse_list(lines, i, child_indent)
|
||||
result[key] = value
|
||||
else:
|
||||
value, i = _parse_mapping(lines, i, child_indent)
|
||||
result[key] = value
|
||||
|
||||
return result, i
|
||||
|
||||
|
||||
def _dump(data: Any, indent: int = 0) -> str:
|
||||
pad = " " * indent
|
||||
if isinstance(data, dict):
|
||||
if not data:
|
||||
return f"{pad}{{}}\n"
|
||||
lines: list[str] = []
|
||||
for key, value in data.items():
|
||||
if isinstance(value, (dict, list)):
|
||||
if isinstance(value, list) and value and all(isinstance(x, str) for x in value):
|
||||
lines.append(f"{pad}{key}:")
|
||||
for item in value:
|
||||
lines.append(f"{pad} - {_scalar(item)}")
|
||||
elif isinstance(value, list) and value and all(isinstance(x, dict) for x in value):
|
||||
lines.append(f"{pad}{key}:")
|
||||
for item in value:
|
||||
lines.extend(_dump_list_dict_item(item, indent + 2))
|
||||
elif isinstance(value, dict) and value:
|
||||
lines.append(f"{pad}{key}:")
|
||||
lines.append(_dump(value, indent + 2).rstrip())
|
||||
elif isinstance(value, list):
|
||||
lines.append(f"{pad}{key}:")
|
||||
for item in value:
|
||||
if isinstance(item, dict):
|
||||
lines.extend(_dump_list_dict_item(item, indent + 2))
|
||||
else:
|
||||
lines.append(f"{pad} - {_scalar(item)}")
|
||||
else:
|
||||
lines.append(f"{pad}{key}: {_scalar(value)}")
|
||||
else:
|
||||
lines.append(f"{pad}{key}: {_scalar(value)}")
|
||||
return "\n".join(lines) + "\n"
|
||||
if isinstance(data, list):
|
||||
lines = []
|
||||
for item in data:
|
||||
if isinstance(item, dict):
|
||||
lines.extend(_dump_list_dict_item(item, indent))
|
||||
else:
|
||||
lines.append(f"{pad}- {_scalar(item)}")
|
||||
return "\n".join(lines) + "\n"
|
||||
return f"{pad}{_scalar(data)}\n"
|
||||
|
||||
|
||||
def _dump_list_dict_item(item: dict[str, Any], indent: int) -> list[str]:
|
||||
pad = " " * indent
|
||||
lines: list[str] = []
|
||||
first = True
|
||||
for key, value in item.items():
|
||||
prefix = f"{pad}- " if first else f"{pad} "
|
||||
first = False
|
||||
if isinstance(value, (dict, list)):
|
||||
lines.append(f"{prefix}{key}:")
|
||||
nested = _dump(value, indent + 4 if first else indent + 2)
|
||||
lines.append(nested.rstrip())
|
||||
else:
|
||||
lines.append(f"{prefix}{key}: {_scalar(value)}")
|
||||
return lines
|
||||
|
||||
|
||||
def _scalar(value: Any) -> str:
|
||||
if value is None:
|
||||
return "null"
|
||||
if isinstance(value, bool):
|
||||
return "true" if value else "false"
|
||||
if isinstance(value, (int, float)):
|
||||
return str(value)
|
||||
text = str(value)
|
||||
if text == "" or any(ch in text for ch in ":#{}[],&*?|>-%@`") or text.startswith((" ", "-")):
|
||||
return json.dumps(text, ensure_ascii=False)
|
||||
return text
|
||||
Reference in New Issue
Block a user