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

110 lines
2.9 KiB
TypeScript

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}`)
}
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')
}