Merge pull request 'feat: add skiff' (#2) from feat/discussion-notes into main

Reviewed-on: laily/.skills#2
This commit was merged in pull request #2.
This commit is contained in:
2026-07-04 10:52:53 +08:00
16 changed files with 1501 additions and 73 deletions
+33
View File
@@ -0,0 +1,33 @@
# Pythonskiff 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.*
+97 -73
View File
@@ -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,10 +54,12 @@ AGENTS.md # 本文档
### 自研(Owned
| Skill | 说明 |
|-------|------|
| Skill | 说明 |
| ---------------------------------------------------------------------- | ------------------------------------------------- |
| [declarative-openspec-loop](skills/declarative-openspec-loop/SKILL.md) | 声明式编程循环:用户提供校验方式,Agent 自动 propose/apply/校验并迭代直到通过 |
| [discussion-notes](skills/discussion-notes/SKILL.md) | 讨论沉淀:边讨论边维护 Markdown 笔记,无 .raw.md |
| [discussion-notes](skills/discussion-notes/SKILL.md) | 讨论沉淀:边讨论边维护 Markdown 笔记,无 .raw.md |
新建 skill:复制 `skills/_template/``skills/<name>/`,编辑 `SKILL.md`,在本仓库 commit。
@@ -65,9 +67,11 @@ AGENTS.md # 本文档
`registry.yaml` 中注册,通过 skiff 拉取安装:
| Skill | 来源 |
|-------|------|
| superpowers | https://github.com/obra/superpowers |
| Skill | 来源 |
| ----------- | -------------------------------------------------------------------------- |
| 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
~/.skillssymlink
@@ -113,19 +116,23 @@ 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` |
| 层级 | 位置 | 维护方式 |
| ---------------- | ---------------------------------- | ------------------------------- |
| **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/` |
| Agent | 全局 | 项目 |
| ----------- | ------------------- | ----------------- |
| Cursor | `~/.cursor/skills/` | `.agents/skills/` |
| Claude Code | `~/.claude/skills/` | `.claude/skills/` |
| Codex | `~/.codex/skills/` | `.agents/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` |
项目级命令:
| 概念 | 类比 |
| -------------- | --------------------------- |
| 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` 自动修复) |
`--target` 可选 `cursor``claude``codex``all`(默认 all)。
| 命令 | 说明 |
| -------------------------------------- | ------------------- |
| `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` | 从 `_template/` 脚手架创建 skill |
| `skiff doctor` | symlink 健康检查 |
---
@@ -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,42 +260,50 @@ 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 |
| 场景 | 建议 |
| -------------- | ----------------------------------------------------- |
| Cursor / Codex | symlink,正常 |
| Claude Code | symlink 单个 skill 目录,不要 symlink 整个 `~/.claude/skills/` |
| 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) |
| 场景 | 工具 |
| -------------- | ---------------------------------------------------- |
| 自研 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>` | 任意 |
| 新建 skill | 复制 `_template/` → 编辑 → commit | 本仓库 |
| 全局启用 | `skiff install <name>` | 任意 |
| 项目启用 | `skiff enable <name>` | 项目目录 |
| 看状态 | `skiff status` | 任意 |
| 装社区 skill | `npx skills add owner/repo -g -y` | 任意 |
| 更新外部 skill | `skiff fetch <name>` | 任意 |
| 我要… | 命令 | 在哪 |
| ---------- | --------------------------------- | ---- |
| 首次 setup | `skiff setup ~/code/gitea/skills` | 任意 |
| 新建 skill | 复制 `_template/` → 编辑 → commit | 本仓库 |
| 全局启用 | `skiff install <name>` | 任意 |
| 项目启用 | `skiff enable <name>` | 项目目录 |
| 看状态 | `skiff status` | 任意 |
| 装社区 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)
+132
View File
@@ -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/ # 自研 skillSSOT
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
~/.skillssymlink
┌────┴────────────────┐
▼ ▼
~/.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)
Executable
+5
View File
@@ -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
View File
@@ -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"
+1
View File
@@ -0,0 +1 @@
# skiff 仅使用 Python 标准库,无第三方依赖
+149
View File
@@ -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)
+3
View File
@@ -0,0 +1,3 @@
"""skiff — Agent Skills 安装与管理 CLI。"""
__version__ = "0.1.0"
+4
View File
@@ -0,0 +1,4 @@
from skiff.cli import main
if __name__ == "__main__":
main()
+429
View File
@@ -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()
+48
View File
@@ -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>"
)
+101
View File
@@ -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)
+33
View File
@@ -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()
+64
View File
@@ -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")
+73
View File
@@ -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
+260
View File
@@ -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