mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2026-07-03 12:27:41 +08:00
### 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>
156 lines
4.9 KiB
TypeScript
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()
|