Files
CherryHQ-cherry-studio/scripts/skills-check.ts
Phantom fe0678a206 chore: migrate .claude/skills to directory symlinks (#13486)
### What this PR does

Before this PR:

`.claude/skills/<name>/SKILL.md` files were **copied** from
`.agents/skills/<name>/SKILL.md` via `pnpm skills:sync`. A dedicated
`skills-check-windows` CI job ran on `windows-latest` to verify
cross-platform file-copy compatibility.

After this PR:

`.claude/skills/<name>` entries are **directory symlinks** pointing to
`../../.agents/skills/<name>`, following the Single Source of Truth
(SSoT) principle. The Windows-specific CI job is removed; Windows
developers are expected to enable symlink support.

### Why we need it and why it was done in this way

The following tradeoffs were made:

- Windows developers must now manually enable symlink support (Developer
Mode + `git config --global core.symlinks true`). This is acceptable
because:
1. The existing `AGENTS.md` is already a symlink, so Windows
compatibility was never fully enforced.
2. Symlinks eliminate the need for file-copy synchronization, reducing
maintenance complexity.
3. Contributors are expected to have sufficient technical capability to
configure their environments.

The following alternatives were considered:

- Keeping file-copy sync: rejected because it duplicates content and
requires extra CI to verify consistency.

### Breaking changes

Windows developers who clone without symlink support enabled will get
plain text files instead of symlinks. They must:
1. Enable Developer Mode or grant `SeCreateSymbolicLinkPrivilege`
2. Run `git config --global core.symlinks true`
3. Re-clone or run `pnpm skills:sync`

### Special notes for your reviewer

- The `.github/workflows/ci.yml` diff includes minor quote-style changes
(`'` → `"`) from the YAML formatter — these are cosmetic only.

### Checklist

- [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
addressed if required
- [x] Documentation: A [user-guide update](https://docs.cherry-ai.com)
was considered and is present (link) or not required. Check this only
when the PR introduces or changes a user-facing feature or behavior.
- [ ] Self-review: I have reviewed my own code (e.g., via
[`/gh-pr-review`](/.claude/skills/gh-pr-review/SKILL.md), `gh pr diff`,
or GitHub UI) before requesting review from others

### Release note

```release-note
NONE
```

Signed-off-by: icarus <eurfelux@gmail.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 08:46:46 +08:00

156 lines
4.9 KiB
TypeScript

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>` is a symlink pointing to
* `../../.agents/skills/<skillName>`.
*/
function checkClaudeSkillSymlink(skillName: string, errors: string[]) {
const claudeSkillDir = path.join(CLAUDE_SKILLS_DIR, skillName)
const expectedTarget = path.join('..', '..', '.agents', 'skills', skillName)
let stat: fs.Stats
try {
stat = fs.lstatSync(claudeSkillDir)
} catch {
errors.push(`.claude/skills/${skillName} is missing (run pnpm skills:sync)`)
return
}
if (!stat.isSymbolicLink()) {
errors.push(
`.claude/skills/${skillName} must be a symlink, not a ${stat.isDirectory() ? 'directory' : 'file'} (run pnpm skills:sync)`
)
return
}
const actualTarget = fs.readlinkSync(claudeSkillDir)
if (actualTarget !== expectedTarget) {
errors.push(`.claude/skills/${skillName} symlink points to '${actualTarget}', expected '${expectedTarget}'`)
}
}
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 allowedClaudeSymlinks = new Set(skillNames.map((skillName) => `.claude/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 (allowedClaudeSymlinks.has(file) || 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 agentSkillDir = path.join(AGENTS_SKILLS_DIR, skillName)
if (!fs.existsSync(agentSkillDir)) {
errors.push(`.agents/skills/${skillName} is missing`)
continue
}
checkClaudeSkillSymlink(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()