diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8a88ed9 --- /dev/null +++ b/.gitignore @@ -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.* diff --git a/AGENTS.md b/AGENTS.md index b697579..54a789a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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//`,编辑 `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//`,不在 Agent 目录直接创建 2. **项目自治** — 每个项目自己维护 `.skills.yaml`,本仓库不维护项目清单 3. **软链优先** — 通过 symlink 映射到 Agent 目录,改 skill 即改 SSOT -4. **一体维护** — skill 内容与 skiff CLI 同仓库,Python 3 直接运行,无需编译 +4. **仓库分离** — skill 内容与 CLI 工具分仓库,互不影响更新 --- ## 架构 ``` -skills 仓库(本仓库) +skills 仓库(本仓库) skiff CLI skills// ←── skiff install / enable registry.yaml ←── skiff add / fetch -skiff/ ←── python3 -m skiff ↑ ~/.skills(symlink) │ @@ -113,19 +116,23 @@ skiff/ ←── python3 -m skiff ### Skill 三层分类 -| 层级 | 位置 | 维护方式 | -|------|------|---------| -| **Owned** | `skills//` | 本仓库 commit | -| **External Git** | `~/.local/share/skills/externals/` | `skiff fetch` | -| **External NPM** | `node_modules/` | `npx skills add` / `skills-npm` | + +| 层级 | 位置 | 维护方式 | +| ---------------- | ---------------------------------- | ------------------------------- | +| **Owned** | `skills//` | 本仓库 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 ` | 关联 `~/.skills` 到本仓库 | -| `skiff list` | 列出所有 skill | -| `skiff status` | 安装状态总览 | -| `skiff install ` | 全局安装(symlink) | -| `skiff uninstall ` | 移除 symlink | -| `skiff add / fetch / install-external` | 外部 Git skill | -| `skiff enable / disable` | 项目级启用/关闭 | -| `skiff sync` | 按 `.skills.yaml` 重建 symlink | -| `skiff create ` | 从 `_template/` 脚手架创建 skill | -| `skiff doctor` | symlink 健康检查(`--fix` 自动修复) | -`--target` 可选 `cursor`、`claude`、`codex`、`all`(默认 all)。 +| 命令 | 说明 | +| -------------------------------------- | ------------------- | +| `skiff setup ` | 关联 `~/.skills` 到本仓库 | +| `skiff list` | 列出所有 skill | +| `skiff status` | 安装状态总览 | +| `skiff install ` | 全局安装(symlink) | +| `skiff uninstall ` | 移除 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 ` | 任意 | -| 新建 skill | 复制 `_template/` → 编辑 → commit | 本仓库 | -| 全局启用 | `skiff install ` | 任意 | -| 项目启用 | `skiff enable ` | 项目目录 | -| 看状态 | `skiff status` | 任意 | -| 装社区 skill | `npx skills add owner/repo -g -y` | 任意 | -| 更新外部 skill | `skiff fetch ` | 任意 | + +| 我要… | 命令 | 在哪 | +| ---------- | --------------------------------- | ---- | +| 首次 setup | `skiff setup ~/code/gitea/skills` | 任意 | +| 新建 skill | 复制 `_template/` → 编辑 → commit | 本仓库 | +| 全局启用 | `skiff install ` | 任意 | +| 项目启用 | `skiff enable ` | 项目目录 | +| 看状态 | `skiff status` | 任意 | +| 装社区 skill | `npx skills add owner/repo -g -y` | 任意 | +| 更新外部 skill | `skiff fetch ` | 任意 | + --- ## 参考 - [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) + diff --git a/README.md b/README.md new file mode 100644 index 0000000..7dd0ab1 --- /dev/null +++ b/README.md @@ -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 # 安装到 ~/.cursor/skills/ 等 +skiff install --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// ←── 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//` +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) diff --git a/bin/skiff b/bin/skiff new file mode 100755 index 0000000..bacc148 --- /dev/null +++ b/bin/skiff @@ -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 "$@" diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..e30be86 --- /dev/null +++ b/install.sh @@ -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" diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..cd8e77f --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +# skiff 仅使用 Python 标准库,无第三方依赖 diff --git a/skiff/README.md b/skiff/README.md new file mode 100644 index 0000000..ae112b9 --- /dev/null +++ b/skiff/README.md @@ -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 +``` + +## 首次配置 + +```bash +skiff setup ~/code/gitea/skills # 将 ~/.skills 软链到仓库 +``` + +若仓库本身就在 `~/.skills`(开发场景),`setup` 会自动识别,无需额外操作。 + +## 命令参考 + +### 仓库关联 + +| 命令 | 说明 | +|------|------| +| `skiff setup ` | 关联 `~/.skills` 到 skills 仓库 | + +### 查看 + +| 命令 | 说明 | +|------|------| +| `skiff list` | 列出自研 skill 与 registry 中的外部 skill | +| `skiff status [--target all\|cursor\|claude\|codex]` | 安装状态总览 | + +### 全局安装(自研 skill) + +| 命令 | 说明 | +|------|------| +| `skiff install [--target all]` | 软链到 Agent 全局目录 | +| `skiff uninstall [--target all]` | 移除软链 | + +全局目标路径: + +| Agent | 路径 | +|-------|------| +| cursor | `~/.cursor/skills/` | +| claude | `~/.claude/skills/` | +| codex | `~/.codex/skills/` | + +### 外部 Git skill + +| 命令 | 说明 | +|------|------| +| `skiff add [--ref main] [--path .]` | 写入 `registry.yaml` | +| `skiff fetch ` | 克隆/更新到 `~/.local/share/skills/externals//` | +| `skiff install-external [--target all]` | 安装外部 skill 到 Agent 目录 | + +### 项目级 + +| 命令 | 说明 | +|------|------| +| `skiff enable [--target all] [--project ]` | 写入 `.skills.yaml` 并创建项目软链 | +| `skiff disable [--target all] [--project ]` | 从 manifest 移除并删除软链 | +| `skiff sync [--target all] [--project ]` | 按 `.skills.yaml` 重建软链 | + +项目目标路径: + +| Agent | 路径 | +|-------|------| +| cursor | `/.agents/skills/` | +| claude | `/.claude/skills/` | +| codex | `/.agents/skills/` | + +### 脚手架与健康检查 + +| 命令 | 说明 | +|------|------| +| `skiff create ` | 从 `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) diff --git a/skiff/__init__.py b/skiff/__init__.py new file mode 100644 index 0000000..41b9419 --- /dev/null +++ b/skiff/__init__.py @@ -0,0 +1,3 @@ +"""skiff — Agent Skills 安装与管理 CLI。""" + +__version__ = "0.1.0" diff --git a/skiff/__main__.py b/skiff/__main__.py new file mode 100644 index 0000000..961c567 --- /dev/null +++ b/skiff/__main__.py @@ -0,0 +1,4 @@ +from skiff.cli import main + +if __name__ == "__main__": + main() diff --git a/skiff/cli.py b/skiff/cli.py new file mode 100644 index 0000000..d18e919 --- /dev/null +++ b/skiff/cli.py @@ -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() diff --git a/skiff/paths.py b/skiff/paths.py new file mode 100644 index 0000000..ddc2386 --- /dev/null +++ b/skiff/paths.py @@ -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 " + ) diff --git a/skiff/project.py b/skiff/project.py new file mode 100644 index 0000000..5070157 --- /dev/null +++ b/skiff/project.py @@ -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) diff --git a/skiff/registry.py b/skiff/registry.py new file mode 100644 index 0000000..50595b3 --- /dev/null +++ b/skiff/registry.py @@ -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() diff --git a/skiff/skills.py b/skiff/skills.py new file mode 100644 index 0000000..f4310a6 --- /dev/null +++ b/skiff/skills.py @@ -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") diff --git a/skiff/symlinks.py b/skiff/symlinks.py new file mode 100644 index 0000000..6039c26 --- /dev/null +++ b/skiff/symlinks.py @@ -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 diff --git a/skiff/yaml_io.py b/skiff/yaml_io.py new file mode 100644 index 0000000..6eb7ab4 --- /dev/null +++ b/skiff/yaml_io.py @@ -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