Files
CherryHQ-cherry-studio/scripts/skills-sync.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

100 lines
2.9 KiB
TypeScript

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>` is a symlink pointing to
* `../../.agents/skills/<skillName>` (relative to `.claude/skills/`).
*/
function ensureClaudeSkillSymlink(skillName: string): boolean {
const agentsSkillDir = path.join(AGENTS_SKILLS_DIR, skillName)
const claudeSkillDir = path.join(CLAUDE_SKILLS_DIR, skillName)
const expectedTarget = path.join('..', '..', '.agents', 'skills', skillName)
if (!fs.existsSync(agentsSkillDir)) {
throw new Error(`.agents/skills/${skillName} is missing`)
}
let existing: fs.Stats | null = null
try {
existing = fs.lstatSync(claudeSkillDir)
} catch (error) {
const nodeError = error as NodeJS.ErrnoException
if (nodeError.code !== 'ENOENT') {
throw error
}
}
if (existing !== null) {
if (existing.isSymbolicLink()) {
const currentTarget = fs.readlinkSync(claudeSkillDir)
if (currentTarget === expectedTarget) {
return false
}
}
fs.rmSync(claudeSkillDir, { force: true, recursive: true })
}
fs.symlinkSync(expectedTarget, claudeSkillDir)
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 (ensureClaudeSkillSymlink(skillName)) {
changedSkillFiles.push(`.claude/skills/${skillName}`)
}
}
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()