mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2026-07-05 21:50:46 +08:00
feat: Optimize PR workflow with on-demand skill loading and project-level skills management (#12943)
### What this PR does Before this PR: - CLAUDE.md contained detailed PR workflow instructions that were loaded in every agent session, consuming unnecessary tokens - No unified project-level skills management mechanism; adding public skills lacked standardization - No automated checks to prevent non-compliant skills from being merged - Team members had no convenient way to share skills with each other After this PR: - Simplified PR instructions in CLAUDE.md, now loaded on-demand via the `gh-create-pr` skill - Introduced project-level skills management (`.agents/skills/` directory + `public-skills.txt` whitelist) - Added `scripts/skills-sync.ts` and `scripts/skills-check.ts` for automated management - Integrated skills validation into CI to prevent non-whitelisted skills from being merged - **Teams can now easily share skills through the project-level mechanism**, with `skills-sync.ts` automatically syncing skills to all team members' local environments, streamlining onboarding and avoiding duplicated configuration efforts - **Optimized PR creation workflow**: `gh-create-pr` skill enforces English PR body writing and displays the draft to users for review before creation, ensuring quality and compliance Fixes # ### Why we need it and why it was done in this way The following tradeoffs were made: - Moved PR workflow from CLAUDE.md to a skill, sacrificing immediate visibility for token efficiency - **Introduced whitelist mechanism (`public-skills.txt`) instead of auto-scanning all files**: Allows developers to freely use private project-level skills in the `.agents/skills/` directory (e.g., team-internal skills, personal customizations). Only skills added to the whitelist are tracked by git and submitted. This ensures standardization for shared skills while preserving development flexibility - Skills exist in both `.agents/skills/` (project-level, shareable) and `.claude/skills/` (local, private) - **Symlink only SKILL.md files instead of entire directories**: On some Windows/restricted filesystems, symlinks may fail or be treated as regular files. If an entire directory is symlinked, failure results in a regular file instead of a directory, causing complete skill failure that's hard to diagnose. Symlinking only SKILL.md allows quick detection when symlinks fail (file content displays directly or errors), reducing troubleshooting costs The following alternatives were considered: - Keeping PR instructions in CLAUDE.md with collapsible blocks, but this still consumes context tokens - Using git hooks for pre-commit checks, but CI checks are more reliable and don't block local development Links to places where the discussion took place: N/A ### Breaking changes None ### Special notes for your reviewer - `gh-create-pr` skill fully implements the project's PR workflow requirements (read template → display body → confirm → create) - `skills-check.ts` validates: 1) tracked skills are in the whitelist; 2) whitelist skills have corresponding files - Process for adding new public skills: 1) create skill files; 2) add to `public-skills.txt`; 3) CI auto-validation - `.claude/skills/` added to `.gitignore` for private skills ### Checklist This checklist is not enforcing, but it's a reminder of items that could be relevant to every PR. Approvers are expected to review this list. - [x] PR: The PR description is expressive enough and will help future contributors - [x] Code: [Write code that humans can understand](https://en.wikiquote.org/wiki/Martin_Fowler#code-for-humans) and [Keep it simple](https://en.wikipedia.org/wiki/KISS_principle) - [x] Refactor: You have [left the code cleaner than you found it (Boy Scout Rule)](https://learning.oreilly.com/library/view/97-things-every/9780596809515/ch08.html) - [x] Upgrade: Impact of this change on upgrade flows was considered and is present (link) or not required. You want a user-guide update if it's a user facing feature. - [ ] Documentation: A user-guide update was considered and is present (link) or not required. You want a user-guide update if it's a user facing feature. ### Release note ```release-note Optimize PR workflow by moving instructions to on-demand skill; introduce project-level skills management with automated validation ```
This commit is contained in:
8
.agents/skills/.gitignore
vendored
Normal file
8
.agents/skills/.gitignore
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
# AUTO-GENERATED by `pnpm skills:sync`.
|
||||
# Do not edit manually.
|
||||
*
|
||||
!.gitignore
|
||||
!README*.md
|
||||
!public-skills.txt
|
||||
!gh-create-pr/
|
||||
!gh-create-pr/**
|
||||
56
.agents/skills/README.md
Normal file
56
.agents/skills/README.md
Normal file
@@ -0,0 +1,56 @@
|
||||
# Skills Management
|
||||
|
||||
This directory is the single source of truth for repository skills.
|
||||
|
||||
## Add a New Skill
|
||||
|
||||
1. Create a new folder under `.agents/skills/<skill-name>/`.
|
||||
2. Add a `SKILL.md` file with:
|
||||
- `name` and `description` in YAML frontmatter
|
||||
- concise workflow instructions in the body
|
||||
3. (Optional) Add `agents/openai.yaml` if Codex UI metadata is needed.
|
||||
4. If this skill should be shared in the repository, append `<skill-name>` to `.agents/skills/public-skills.txt`.
|
||||
|
||||
## Naming Rules
|
||||
|
||||
- Use lowercase letters, digits, and hyphens only.
|
||||
- Prefer short, action-oriented names (for example: `gh-create-pr`).
|
||||
|
||||
## Claude Compatibility
|
||||
|
||||
For each new public skill, run:
|
||||
|
||||
```bash
|
||||
pnpm skills:sync
|
||||
```
|
||||
|
||||
`skills:sync` will create/update `.claude/skills/<skill-name>/SKILL.md` as:
|
||||
|
||||
- a copied file from `.agents/skills/<skill-name>/SKILL.md`.
|
||||
- symlinks are not allowed; check enforces regular files for compatibility.
|
||||
|
||||
## White-list Tracking Rules
|
||||
|
||||
The public white-list is defined in `.agents/skills/public-skills.txt`.
|
||||
|
||||
- Skills listed there are synced to both `.agents/skills/.gitignore` and `.claude/skills/.gitignore`.
|
||||
- Private/local-only skills should stay out of `public-skills.txt`.
|
||||
- Use one skill name per line. Comment lines must start with `#` and cannot be appended inline.
|
||||
|
||||
After updating `public-skills.txt`, run:
|
||||
|
||||
```bash
|
||||
pnpm skills:sync
|
||||
```
|
||||
|
||||
Then validate:
|
||||
|
||||
```bash
|
||||
pnpm skills:check
|
||||
```
|
||||
|
||||
The sync/check scripts manage and verify:
|
||||
|
||||
- `.agents/skills/.gitignore`
|
||||
- `.claude/skills/.gitignore`
|
||||
- `.claude/skills/<skill-name>/SKILL.md` content matches `.agents/skills/<skill-name>/SKILL.md`
|
||||
56
.agents/skills/README.zh.md
Normal file
56
.agents/skills/README.zh.md
Normal file
@@ -0,0 +1,56 @@
|
||||
# Skills 管理说明
|
||||
|
||||
本目录是仓库内 skills 的唯一维护来源(single source of truth)。
|
||||
|
||||
## 新增 Skill 流程
|
||||
|
||||
1. 在 `.agents/skills/<skill-name>/` 下创建新目录。
|
||||
2. 添加 `SKILL.md`,包含:
|
||||
- YAML frontmatter 中的 `name` 和 `description`
|
||||
- 正文中的精简流程说明
|
||||
3. (可选)如需 Codex UI 元数据,添加 `agents/openai.yaml`。
|
||||
4. 若该 skill 需要作为仓库公共 skill 跟踪,请将 `<skill-name>` 追加到 `.agents/skills/public-skills.txt`。
|
||||
|
||||
## 命名规则
|
||||
|
||||
- 仅使用小写字母、数字和连字符(`-`)。
|
||||
- 优先使用简短、动作导向的名称(例如:`gh-create-pr`)。
|
||||
|
||||
## Claude 兼容
|
||||
|
||||
每个新增的公共 skill,请执行:
|
||||
|
||||
```bash
|
||||
pnpm skills:sync
|
||||
```
|
||||
|
||||
`skills:sync` 会自动创建/更新 `.claude/skills/<skill-name>/SKILL.md`:
|
||||
|
||||
- 复制 `.agents/skills/<skill-name>/SKILL.md` 的内容。
|
||||
- 不允许使用符号链接;check 会强制要求为普通文件以保证兼容性。
|
||||
|
||||
## 白名单跟踪规则
|
||||
|
||||
公共白名单由 `.agents/skills/public-skills.txt` 定义。
|
||||
|
||||
- 写入该文件的 skill 会同步到 `.agents/skills/.gitignore` 和 `.claude/skills/.gitignore`。
|
||||
- 私有/仅本地使用的 skill 不应写入 `public-skills.txt`。
|
||||
- 每行只写一个 skill 名称。注释行必须以 `#` 开头,不能写行尾注释。
|
||||
|
||||
更新 `public-skills.txt` 后,请执行:
|
||||
|
||||
```bash
|
||||
pnpm skills:sync
|
||||
```
|
||||
|
||||
然后校验:
|
||||
|
||||
```bash
|
||||
pnpm skills:check
|
||||
```
|
||||
|
||||
上述脚本会自动维护并校验:
|
||||
|
||||
- `.agents/skills/.gitignore`
|
||||
- `.claude/skills/.gitignore`
|
||||
- `.claude/skills/<skill-name>/SKILL.md` 与 `.agents/skills/<skill-name>/SKILL.md` 的内容一致性
|
||||
45
.agents/skills/gh-create-pr/SKILL.md
Normal file
45
.agents/skills/gh-create-pr/SKILL.md
Normal file
@@ -0,0 +1,45 @@
|
||||
---
|
||||
name: gh-create-pr
|
||||
description: Create or update GitHub pull requests using the repository-required workflow and template compliance. Use when asked to create/open/update a PR so the assistant reads `.github/pull_request_template.md`, fills every template section, preserves markdown structure exactly, and marks missing data as N/A or None instead of skipping sections.
|
||||
---
|
||||
|
||||
# GitHub PR Creation
|
||||
|
||||
## Workflow
|
||||
|
||||
1. Read `.github/pull_request_template.md` before drafting the PR body.
|
||||
2. Collect PR context from the current branch (base/head, scope, linked issues, testing status, breaking changes, release note content).
|
||||
3. Draft the PR body using the template structure exactly:
|
||||
- Keep section order and headings.
|
||||
- Keep checkbox and code block formatting.
|
||||
- Fill every section; if not applicable, write `N/A` or `None`.
|
||||
4. Present the full Markdown PR body in chat for review before creating the PR.
|
||||
5. Ask for explicit confirmation to create the PR with that body.
|
||||
6. After confirmation, create the PR with `gh pr create --body-file` using a unique temp file path.
|
||||
7. Report the created PR URL and summarize title/base/head and any required follow-up.
|
||||
|
||||
## Constraints
|
||||
|
||||
- Never skip template sections.
|
||||
- Never rewrite the template format.
|
||||
- Keep content concise and specific to the current change set.
|
||||
- PR title and body must be written in English.
|
||||
- Never create the PR before showing the full final body to the user.
|
||||
- Never rely on command permission prompts as PR body preview.
|
||||
|
||||
## Command Pattern
|
||||
|
||||
```bash
|
||||
# read template
|
||||
cat .github/pull_request_template.md
|
||||
|
||||
# show this full Markdown body in chat first
|
||||
pr_body_file="$(mktemp /tmp/gh-pr-body-XXXXXX).md"
|
||||
cat > "$pr_body_file" <<'EOF'
|
||||
...filled template body...
|
||||
EOF
|
||||
|
||||
# run only after explicit user confirmation
|
||||
gh pr create --base <base> --head <head> --title "<title>" --body-file "$pr_body_file"
|
||||
rm -f "$pr_body_file"
|
||||
```
|
||||
4
.agents/skills/gh-create-pr/agents/openai.yaml
Normal file
4
.agents/skills/gh-create-pr/agents/openai.yaml
Normal file
@@ -0,0 +1,4 @@
|
||||
interface:
|
||||
display_name: "Create GitHub PR"
|
||||
short_description: "Create PRs with required template compliance"
|
||||
default_prompt: "Create a pull request for my current branch using the repository template workflow."
|
||||
3
.agents/skills/public-skills.txt
Normal file
3
.agents/skills/public-skills.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
# Public skills tracked by skills:sync and skills:check.
|
||||
# One skill name per line.
|
||||
gh-create-pr
|
||||
7
.claude/skills/.gitignore
vendored
Normal file
7
.claude/skills/.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
# AUTO-GENERATED by `pnpm skills:sync`.
|
||||
# Do not edit manually.
|
||||
*
|
||||
!.gitignore
|
||||
!README*.md
|
||||
!gh-create-pr/
|
||||
!gh-create-pr/**
|
||||
8
.claude/skills/README.md
Normal file
8
.claude/skills/README.md
Normal file
@@ -0,0 +1,8 @@
|
||||
# Claude Skills Mirror
|
||||
|
||||
This directory is a synced mirror for Claude-compatible skill files.
|
||||
|
||||
- Do not create new skills directly under `.claude/skills`.
|
||||
- Create and maintain skills under `.agents/skills` only.
|
||||
- Update `.agents/skills/public-skills.txt`, then run `pnpm skills:sync`.
|
||||
- `pnpm skills:check` verifies `.claude/skills/<skill>/SKILL.md` matches `.agents/skills/<skill>/SKILL.md`.
|
||||
8
.claude/skills/README.zh.md
Normal file
8
.claude/skills/README.zh.md
Normal file
@@ -0,0 +1,8 @@
|
||||
# Claude Skills 镜像说明
|
||||
|
||||
本目录是面向 Claude 的 skill 文件镜像目录。
|
||||
|
||||
- 不要直接在 `.claude/skills` 下创建新 skill。
|
||||
- 所有 skill 仅在 `.agents/skills` 中创建和维护。
|
||||
- 更新 `.agents/skills/public-skills.txt` 后,执行 `pnpm skills:sync`。
|
||||
- `pnpm skills:check` 会校验 `.claude/skills/<skill>/SKILL.md` 与 `.agents/skills/<skill>/SKILL.md` 内容一致。
|
||||
45
.claude/skills/gh-create-pr/SKILL.md
Normal file
45
.claude/skills/gh-create-pr/SKILL.md
Normal file
@@ -0,0 +1,45 @@
|
||||
---
|
||||
name: gh-create-pr
|
||||
description: Create or update GitHub pull requests using the repository-required workflow and template compliance. Use when asked to create/open/update a PR so the assistant reads `.github/pull_request_template.md`, fills every template section, preserves markdown structure exactly, and marks missing data as N/A or None instead of skipping sections.
|
||||
---
|
||||
|
||||
# GitHub PR Creation
|
||||
|
||||
## Workflow
|
||||
|
||||
1. Read `.github/pull_request_template.md` before drafting the PR body.
|
||||
2. Collect PR context from the current branch (base/head, scope, linked issues, testing status, breaking changes, release note content).
|
||||
3. Draft the PR body using the template structure exactly:
|
||||
- Keep section order and headings.
|
||||
- Keep checkbox and code block formatting.
|
||||
- Fill every section; if not applicable, write `N/A` or `None`.
|
||||
4. Present the full Markdown PR body in chat for review before creating the PR.
|
||||
5. Ask for explicit confirmation to create the PR with that body.
|
||||
6. After confirmation, create the PR with `gh pr create --body-file` using a unique temp file path.
|
||||
7. Report the created PR URL and summarize title/base/head and any required follow-up.
|
||||
|
||||
## Constraints
|
||||
|
||||
- Never skip template sections.
|
||||
- Never rewrite the template format.
|
||||
- Keep content concise and specific to the current change set.
|
||||
- PR title and body must be written in English.
|
||||
- Never create the PR before showing the full final body to the user.
|
||||
- Never rely on command permission prompts as PR body preview.
|
||||
|
||||
## Command Pattern
|
||||
|
||||
```bash
|
||||
# read template
|
||||
cat .github/pull_request_template.md
|
||||
|
||||
# show this full Markdown body in chat first
|
||||
pr_body_file="$(mktemp /tmp/gh-pr-body-XXXXXX).md"
|
||||
cat > "$pr_body_file" <<'EOF'
|
||||
...filled template body...
|
||||
EOF
|
||||
|
||||
# run only after explicit user confirmation
|
||||
gh pr create --base <base> --head <head> --title "<title>" --body-file "$pr_body_file"
|
||||
rm -f "$pr_body_file"
|
||||
```
|
||||
44
.github/workflows/ci.yml
vendored
44
.github/workflows/ci.yml
vendored
@@ -74,6 +74,10 @@ jobs:
|
||||
if: matrix.task == 'basic-checks'
|
||||
run: pnpm i18n:hardcoded:strict
|
||||
|
||||
- name: Skills Check
|
||||
if: matrix.task == 'basic-checks'
|
||||
run: pnpm skills:check
|
||||
|
||||
- name: Main Process Test
|
||||
if: matrix.task == 'general-test'
|
||||
run: pnpm test:main
|
||||
@@ -94,10 +98,46 @@ jobs:
|
||||
if: matrix.task == 'render-test'
|
||||
run: pnpm test:renderer
|
||||
|
||||
skills-check-windows:
|
||||
runs-on: windows-latest
|
||||
env:
|
||||
CI: true
|
||||
if: github.event_name == 'push' || github.event_name == 'workflow_dispatch' || github.event.pull_request.draft == false
|
||||
steps:
|
||||
- name: Check out Git repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
|
||||
- name: Get pnpm store directory
|
||||
id: pnpm-cache
|
||||
shell: bash
|
||||
run: echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Cache pnpm dependencies
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: ${{ steps.pnpm-cache.outputs.STORE_PATH }}
|
||||
key: ${{ runner.os }}-pnpm-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pnpm-
|
||||
|
||||
- name: Install Dependencies
|
||||
run: pnpm install
|
||||
|
||||
- name: Skills Check
|
||||
run: pnpm skills:check
|
||||
|
||||
notify:
|
||||
runs-on: ubuntu-latest
|
||||
needs: ci
|
||||
if: always() && needs.ci.result == 'failure' && github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
needs: [ci, skills-check-windows]
|
||||
if: always() && (needs.ci.result == 'failure' || needs.skills-check-windows.result == 'failure') && github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
steps:
|
||||
- name: Check out Git repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -50,6 +50,8 @@ local
|
||||
.cursorrules
|
||||
.cursor/*
|
||||
.claude/*
|
||||
!.claude/skills/
|
||||
!.claude/skills/**
|
||||
.gemini/*
|
||||
.qwen/*
|
||||
.trae/*
|
||||
|
||||
@@ -15,12 +15,8 @@ This file provides guidance to AI coding assistants when working with code in th
|
||||
|
||||
## Pull Request Workflow (CRITICAL)
|
||||
|
||||
When creating a Pull Request, you MUST:
|
||||
|
||||
1. **Read the PR template first**: Always read `.github/pull_request_template.md` before creating the PR
|
||||
2. **Follow ALL template sections**: Structure the `--body` parameter to include every section from the template
|
||||
3. **Never skip sections**: Include all sections even if marking them as N/A or "None"
|
||||
4. **Use proper formatting**: Match the template's markdown structure exactly (headings, checkboxes, code blocks)
|
||||
When creating a Pull Request, you MUST use the `gh-create-pr` skill.
|
||||
If the skill is unavailable, directly read `.agents/skills/gh-create-pr/SKILL.md` and follow it manually.
|
||||
|
||||
## Development Commands
|
||||
|
||||
|
||||
@@ -45,6 +45,8 @@
|
||||
"i18n:sync": "dotenv -e .env -- tsx scripts/sync-i18n.ts",
|
||||
"i18n:translate": "dotenv -e .env -- tsx scripts/auto-translate-i18n.ts",
|
||||
"i18n:all": "pnpm i18n:sync && pnpm i18n:translate",
|
||||
"skills:sync": "tsx scripts/skills-sync.ts",
|
||||
"skills:check": "tsx scripts/skills-check.ts",
|
||||
"update:languages": "tsx scripts/update-languages.ts",
|
||||
"update:upgrade-config": "tsx scripts/update-app-upgrade-config.ts",
|
||||
"test": "vitest run --silent",
|
||||
|
||||
175
scripts/skills-check.ts
Normal file
175
scripts/skills-check.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
import { execSync } from 'child_process'
|
||||
import * as fs from 'fs'
|
||||
import * as path from 'path'
|
||||
|
||||
import {
|
||||
AGENTS_SKILLS_DIR,
|
||||
AGENTS_SKILLS_GITIGNORE,
|
||||
buildAgentsSkillsGitignore,
|
||||
buildClaudeSkillsGitignore,
|
||||
CLAUDE_SKILLS_DIR,
|
||||
CLAUDE_SKILLS_GITIGNORE,
|
||||
listSkillNames,
|
||||
readFileSafe,
|
||||
ROOT_DIR
|
||||
} from './skills-common'
|
||||
|
||||
function isAgentsReadmeFile(file: string): boolean {
|
||||
return /^\.agents\/skills\/README(?:\.[a-z0-9-]+)?\.md$/i.test(file)
|
||||
}
|
||||
|
||||
function isClaudeReadmeFile(file: string): boolean {
|
||||
return /^\.claude\/skills\/README(?:\.[a-z0-9-]+)?\.md$/i.test(file)
|
||||
}
|
||||
|
||||
function checkGitignore(filePath: string, expected: string, displayPath: string, errors: string[]) {
|
||||
const actual = readFileSafe(filePath)
|
||||
if (actual === null) {
|
||||
errors.push(`${displayPath} is missing`)
|
||||
return
|
||||
}
|
||||
if (actual !== expected) {
|
||||
errors.push(`${displayPath} is out of date (run pnpm skills:sync)`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies `.claude/skills/<skillName>/SKILL.md` is correctly synced with
|
||||
* `.agents/skills/<skillName>/SKILL.md`.
|
||||
* Requires regular files (symlinks are disallowed for cross-platform compatibility).
|
||||
*/
|
||||
function checkClaudeSkillFile(skillName: string, errors: string[]) {
|
||||
const skillDir = path.join(CLAUDE_SKILLS_DIR, skillName)
|
||||
const skillFile = path.join(skillDir, 'SKILL.md')
|
||||
const agentsSkillFile = path.join(AGENTS_SKILLS_DIR, skillName, 'SKILL.md')
|
||||
|
||||
if (!fs.existsSync(skillDir)) {
|
||||
errors.push(`.claude/skills/${skillName} is missing`)
|
||||
return
|
||||
}
|
||||
|
||||
if (!fs.statSync(skillDir).isDirectory()) {
|
||||
errors.push(`.claude/skills/${skillName} is not a directory`)
|
||||
return
|
||||
}
|
||||
|
||||
let stat: fs.Stats
|
||||
try {
|
||||
stat = fs.lstatSync(skillFile)
|
||||
} catch {
|
||||
errors.push(`.claude/skills/${skillName}/SKILL.md is missing`)
|
||||
return
|
||||
}
|
||||
|
||||
if (stat.isSymbolicLink()) {
|
||||
errors.push(`.claude/skills/${skillName}/SKILL.md must be a regular file, not a symlink`)
|
||||
return
|
||||
}
|
||||
|
||||
if (!stat.isFile()) {
|
||||
errors.push(`.claude/skills/${skillName}/SKILL.md is not a regular file`)
|
||||
return
|
||||
}
|
||||
|
||||
const expectedContent = readFileSafe(agentsSkillFile)
|
||||
const actualContent = readFileSafe(skillFile)
|
||||
if (expectedContent === null || actualContent === null) {
|
||||
errors.push(`failed to read .claude/skills/${skillName}/SKILL.md for content verification`)
|
||||
return
|
||||
}
|
||||
|
||||
if (actualContent !== expectedContent) {
|
||||
errors.push(`.claude/skills/${skillName}/SKILL.md content differs from .agents/skills/${skillName}/SKILL.md`)
|
||||
}
|
||||
}
|
||||
|
||||
function checkTrackedFilesAgainstWhitelist(skillNames: string[], errors: string[]) {
|
||||
const sharedAgentsFiles = new Set(['.agents/skills/.gitignore', '.agents/skills/public-skills.txt'])
|
||||
const sharedClaudeFiles = new Set(['.claude/skills/.gitignore'])
|
||||
const allowedAgentsPrefixes = skillNames.map((skillName) => `.agents/skills/${skillName}/`)
|
||||
const allowedClaudePrefixes = skillNames.map((skillName) => `.claude/skills/${skillName}/`)
|
||||
|
||||
let trackedFiles: string[]
|
||||
try {
|
||||
const output = execSync('git ls-files -- .agents/skills .claude/skills', {
|
||||
cwd: ROOT_DIR,
|
||||
encoding: 'utf-8'
|
||||
})
|
||||
trackedFiles = output
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.length > 0)
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
errors.push(`failed to read tracked skill files via git ls-files: ${message}`)
|
||||
return
|
||||
}
|
||||
|
||||
for (const file of trackedFiles) {
|
||||
if (file.startsWith('.agents/skills/')) {
|
||||
if (sharedAgentsFiles.has(file) || isAgentsReadmeFile(file)) {
|
||||
continue
|
||||
}
|
||||
if (allowedAgentsPrefixes.some((prefix) => file.startsWith(prefix))) {
|
||||
continue
|
||||
}
|
||||
errors.push(`tracked file is outside public skill whitelist: ${file}`)
|
||||
continue
|
||||
}
|
||||
|
||||
if (file.startsWith('.claude/skills/')) {
|
||||
if (sharedClaudeFiles.has(file) || isClaudeReadmeFile(file)) {
|
||||
continue
|
||||
}
|
||||
if (allowedClaudePrefixes.some((prefix) => file.startsWith(prefix))) {
|
||||
continue
|
||||
}
|
||||
errors.push(`tracked file is outside public skill whitelist: ${file}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates public skills governance:
|
||||
* - generated gitignore files are up to date
|
||||
* - Claude skill files match source skills by content
|
||||
* - tracked skill files do not exceed the public whitelist
|
||||
*/
|
||||
function main() {
|
||||
let skillNames: string[]
|
||||
try {
|
||||
skillNames = listSkillNames()
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
console.error(`skills:check failed: ${message}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const errors: string[] = []
|
||||
|
||||
checkGitignore(AGENTS_SKILLS_GITIGNORE, buildAgentsSkillsGitignore(skillNames), '.agents/skills/.gitignore', errors)
|
||||
checkGitignore(CLAUDE_SKILLS_GITIGNORE, buildClaudeSkillsGitignore(skillNames), '.claude/skills/.gitignore', errors)
|
||||
|
||||
for (const skillName of skillNames) {
|
||||
const agentSkillPath = path.join(AGENTS_SKILLS_DIR, skillName, 'SKILL.md')
|
||||
if (!fs.existsSync(agentSkillPath)) {
|
||||
errors.push(`.agents/skills/${skillName}/SKILL.md is missing`)
|
||||
continue
|
||||
}
|
||||
|
||||
checkClaudeSkillFile(skillName, errors)
|
||||
}
|
||||
checkTrackedFilesAgainstWhitelist(skillNames, errors)
|
||||
|
||||
if (errors.length > 0) {
|
||||
console.error('skills:check failed')
|
||||
for (const error of errors) {
|
||||
console.error(`- ${error}`)
|
||||
}
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
console.log(`skills:check passed (${skillNames.length} public skill${skillNames.length === 1 ? '' : 's'})`)
|
||||
}
|
||||
|
||||
main()
|
||||
110
scripts/skills-common.ts
Normal file
110
scripts/skills-common.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import * as fs from 'fs'
|
||||
import * as path from 'path'
|
||||
|
||||
export const ROOT_DIR = path.join(__dirname, '..')
|
||||
export const AGENTS_SKILLS_DIR = path.join(ROOT_DIR, '.agents', 'skills')
|
||||
export const CLAUDE_SKILLS_DIR = path.join(ROOT_DIR, '.claude', 'skills')
|
||||
export const AGENTS_SKILLS_GITIGNORE = path.join(AGENTS_SKILLS_DIR, '.gitignore')
|
||||
export const CLAUDE_SKILLS_GITIGNORE = path.join(CLAUDE_SKILLS_DIR, '.gitignore')
|
||||
export const PUBLIC_SKILLS_FILE = path.join(AGENTS_SKILLS_DIR, 'public-skills.txt')
|
||||
|
||||
const SKILL_NAME_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/
|
||||
|
||||
export function listSkillNames(): string[] {
|
||||
const content = readFileSafe(PUBLIC_SKILLS_FILE)
|
||||
if (content === null) {
|
||||
throw new Error('.agents/skills/public-skills.txt is missing')
|
||||
}
|
||||
|
||||
const names: string[] = []
|
||||
const seen = new Set<string>()
|
||||
const lines = content.split('\n')
|
||||
|
||||
for (const [index, rawLine] of lines.entries()) {
|
||||
const trimmedLine = rawLine.trim()
|
||||
if (trimmedLine === '' || trimmedLine.startsWith('#')) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (trimmedLine.includes('#')) {
|
||||
throw new Error(
|
||||
`inline comments are not allowed at .agents/skills/public-skills.txt:${index + 1}; ` +
|
||||
'put comments on the previous line'
|
||||
)
|
||||
}
|
||||
|
||||
const name = trimmedLine
|
||||
|
||||
if (!SKILL_NAME_PATTERN.test(name)) {
|
||||
throw new Error(`invalid skill name '${name}' at .agents/skills/public-skills.txt:${index + 1}`)
|
||||
}
|
||||
|
||||
if (seen.has(name)) {
|
||||
throw new Error(`duplicate skill name '${name}' at .agents/skills/public-skills.txt:${index + 1}`)
|
||||
}
|
||||
|
||||
seen.add(name)
|
||||
names.push(name)
|
||||
}
|
||||
|
||||
return names.sort((a, b) => a.localeCompare(b))
|
||||
}
|
||||
|
||||
export function buildAgentsSkillsGitignore(skillNames: string[]): string {
|
||||
const lines = [
|
||||
'# AUTO-GENERATED by `pnpm skills:sync`.',
|
||||
'# Do not edit manually.',
|
||||
'*',
|
||||
'!.gitignore',
|
||||
'!README*.md',
|
||||
'!public-skills.txt'
|
||||
]
|
||||
|
||||
for (const skillName of skillNames) {
|
||||
lines.push(`!${skillName}/`)
|
||||
lines.push(`!${skillName}/**`)
|
||||
}
|
||||
|
||||
return `${lines.join('\n')}\n`
|
||||
}
|
||||
|
||||
export function buildClaudeSkillsGitignore(skillNames: string[]): string {
|
||||
const lines = [
|
||||
'# AUTO-GENERATED by `pnpm skills:sync`.',
|
||||
'# Do not edit manually.',
|
||||
'*',
|
||||
'!.gitignore',
|
||||
'!README*.md'
|
||||
]
|
||||
|
||||
for (const skillName of skillNames) {
|
||||
lines.push(`!${skillName}/`)
|
||||
lines.push(`!${skillName}/**`)
|
||||
}
|
||||
|
||||
return `${lines.join('\n')}\n`
|
||||
}
|
||||
|
||||
export function writeFileIfChanged(filePath: string, content: string): boolean {
|
||||
let current = ''
|
||||
try {
|
||||
current = fs.readFileSync(filePath, 'utf-8')
|
||||
} catch (error) {
|
||||
const nodeError = error as NodeJS.ErrnoException
|
||||
if (nodeError.code !== 'ENOENT') {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
if (current === content) {
|
||||
return false
|
||||
}
|
||||
fs.writeFileSync(filePath, content, 'utf-8')
|
||||
return true
|
||||
}
|
||||
|
||||
export function readFileSafe(filePath: string): string | null {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return null
|
||||
}
|
||||
return fs.readFileSync(filePath, 'utf-8')
|
||||
}
|
||||
104
scripts/skills-sync.ts
Normal file
104
scripts/skills-sync.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import * as fs from 'fs'
|
||||
import * as path from 'path'
|
||||
|
||||
import { AGENTS_SKILLS_DIR, CLAUDE_SKILLS_DIR } from './skills-common'
|
||||
import {
|
||||
AGENTS_SKILLS_GITIGNORE,
|
||||
buildAgentsSkillsGitignore,
|
||||
buildClaudeSkillsGitignore,
|
||||
CLAUDE_SKILLS_GITIGNORE,
|
||||
listSkillNames,
|
||||
writeFileIfChanged
|
||||
} from './skills-common'
|
||||
|
||||
/**
|
||||
* Ensures `.claude/skills/<skillName>/SKILL.md` is synchronized with
|
||||
* `.agents/skills/<skillName>/SKILL.md`.
|
||||
* Uses file copy to keep cross-platform compatibility.
|
||||
*/
|
||||
function ensureClaudeSkillFile(skillName: string): boolean {
|
||||
const agentsSkillFile = path.join(AGENTS_SKILLS_DIR, skillName, 'SKILL.md')
|
||||
const claudeSkillDir = path.join(CLAUDE_SKILLS_DIR, skillName)
|
||||
const claudeSkillFile = path.join(claudeSkillDir, 'SKILL.md')
|
||||
|
||||
if (!fs.existsSync(agentsSkillFile)) {
|
||||
throw new Error(`.agents/skills/${skillName}/SKILL.md is missing`)
|
||||
}
|
||||
|
||||
fs.mkdirSync(claudeSkillDir, { recursive: true })
|
||||
|
||||
const expectedContent = fs.readFileSync(agentsSkillFile, 'utf-8')
|
||||
|
||||
let existing: fs.Stats | null = null
|
||||
try {
|
||||
existing = fs.lstatSync(claudeSkillFile)
|
||||
} catch (error) {
|
||||
const nodeError = error as NodeJS.ErrnoException
|
||||
if (nodeError.code !== 'ENOENT') {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
if (existing !== null && !existing.isFile()) {
|
||||
fs.rmSync(claudeSkillFile, { force: true, recursive: true })
|
||||
existing = null
|
||||
} else if (existing?.isFile()) {
|
||||
const currentContent = fs.readFileSync(claudeSkillFile, 'utf-8')
|
||||
if (currentContent === expectedContent) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
fs.writeFileSync(claudeSkillFile, expectedContent, 'utf-8')
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronizes skill infrastructure for all public skills:
|
||||
* - regenerates whitelist gitignore files
|
||||
* - syncs Claude-side SKILL.md files
|
||||
*/
|
||||
function main() {
|
||||
let skillNames: string[]
|
||||
try {
|
||||
skillNames = listSkillNames()
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
console.error(`skills:sync failed: ${message}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const agentsGitignore = buildAgentsSkillsGitignore(skillNames)
|
||||
const claudeGitignore = buildClaudeSkillsGitignore(skillNames)
|
||||
|
||||
const changedFiles: string[] = []
|
||||
const changedSkillFiles: string[] = []
|
||||
|
||||
if (writeFileIfChanged(AGENTS_SKILLS_GITIGNORE, agentsGitignore)) {
|
||||
changedFiles.push('.agents/skills/.gitignore')
|
||||
}
|
||||
if (writeFileIfChanged(CLAUDE_SKILLS_GITIGNORE, claudeGitignore)) {
|
||||
changedFiles.push('.claude/skills/.gitignore')
|
||||
}
|
||||
for (const skillName of skillNames) {
|
||||
if (ensureClaudeSkillFile(skillName)) {
|
||||
changedSkillFiles.push(`.claude/skills/${skillName}/SKILL.md`)
|
||||
}
|
||||
}
|
||||
|
||||
if (changedFiles.length === 0 && changedSkillFiles.length === 0) {
|
||||
console.log(`skills:sync up-to-date (${skillNames.length} public skill${skillNames.length === 1 ? '' : 's'})`)
|
||||
return
|
||||
}
|
||||
|
||||
const updatedCount = changedFiles.length + changedSkillFiles.length
|
||||
console.log(`skills:sync updated ${updatedCount} file${updatedCount === 1 ? '' : 's'}:`)
|
||||
for (const file of changedFiles) {
|
||||
console.log(`- ${file}`)
|
||||
}
|
||||
for (const file of changedSkillFiles) {
|
||||
console.log(`- ${file}`)
|
||||
}
|
||||
}
|
||||
|
||||
main()
|
||||
Reference in New Issue
Block a user