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:
Phantom
2026-02-17 00:37:32 +08:00
committed by GitHub
parent e31029b0e9
commit e61e1bb672
17 changed files with 677 additions and 8 deletions

8
.agents/skills/.gitignore vendored Normal file
View 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
View 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`

View 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` 的内容一致性

View 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"
```

View 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."

View 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
View 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
View 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`.

View 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` 内容一致。

View 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"
```

View File

@@ -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
View File

@@ -50,6 +50,8 @@ local
.cursorrules
.cursor/*
.claude/*
!.claude/skills/
!.claude/skills/**
.gemini/*
.qwen/*
.trae/*

View File

@@ -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

View File

@@ -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
View 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
View 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
View 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()