community-skills.test.ts 9.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158
  1. import { execFileSync } from 'node:child_process'
  2. import fs from 'node:fs'
  3. import os from 'node:os'
  4. import path from 'node:path'
  5. import { describe, expect, it } from 'vitest'
  6. import { resolveSkills } from '../src/providers/claude/skills'
  7. import { skillsToDisable } from '../src/providers/codex/skills'
  8. import { CommunitySkillService } from '../src/services/community-skills'
  9. import { frontmatter } from '../src/lib/skill-files'
  10. const SHA1 = 'c'.repeat(40)
  11. const SHA2 = 'd'.repeat(40)
  12. /** 构造与 GitHub codeload 相同格式的仓库快照(顶层目录 repo-<sha>/) */
  13. function tarball(sha: string, files: Record<string, string>, links: Record<string, string> = {}): Buffer {
  14. const root = fs.mkdtempSync(path.join(os.tmpdir(), 'crepo-'))
  15. const top = path.join(root, `repo-${sha}`)
  16. for (const [rel, body] of Object.entries(files)) {
  17. fs.mkdirSync(path.dirname(path.join(top, rel)), { recursive: true })
  18. fs.writeFileSync(path.join(top, rel), body)
  19. }
  20. for (const [rel, target] of Object.entries(links)) fs.symlinkSync(target, path.join(top, rel))
  21. const out = path.join(root, 'repo.tar.gz')
  22. execFileSync('tar', ['-czf', out, '-C', root, `repo-${sha}`])
  23. return fs.readFileSync(out)
  24. }
  25. const skill = (name: string, extra = '') => `---\nname: ${name}\ndescription: >-\n ${name} does\n things\n${extra}---\nbody\n`
  26. describe('CommunitySkillService', () => {
  27. const base = fs.mkdtempSync(path.join(os.tmpdir(), 'community-'))
  28. const roots = { cache: path.join(base, 'cache'), plugin: path.join(base, 'community-skills') }
  29. const heads: Record<string, string> = { 'acme/tools': SHA1, 'acme/closed': SHA1, 'other/tools': SHA1 }
  30. const repos: Record<string, Buffer> = {
  31. [`acme/tools@${SHA1}`]: tarball(SHA1, {
  32. 'LICENSE': 'MIT License\n\nPermission is hereby granted, free of charge',
  33. 'skills/csv-clean/SKILL.md': skill('csv-clean'),
  34. 'skills/csv-clean/scripts/clean.py': 'print(1)',
  35. 'skills/renamed-dir/SKILL.md': skill('chart-kit'),
  36. 'docs/csv-clean/SKILL.md': skill('csv-clean-docs'),
  37. }, { 'skills/csv-clean/passwd': '/etc/passwd' }),
  38. [`acme/tools@${SHA2}`]: tarball(SHA2, {
  39. 'LICENSE': 'MIT License',
  40. 'skills/csv-clean/SKILL.md': skill('csv-clean', 'version: 2\n'),
  41. }),
  42. [`acme/closed@${SHA1}`]: tarball(SHA1, {
  43. 'skills/secret/SKILL.md': skill('secret', 'license: Complete terms in LICENSE.txt\n'),
  44. 'skills/secret/LICENSE.txt': '© Anthropic, PBC. All rights reserved. Use is governed by your agreement with Anthropic.',
  45. 'skills/nolicense/SKILL.md': skill('nolicense'),
  46. 'skills/declared/SKILL.md': skill('declared', 'license: Apache-2.0\n'),
  47. }),
  48. [`other/tools@${SHA1}`]: tarball(SHA1, { 'LICENSE': 'Apache License\nVersion 2.0', 'csv-clean/SKILL.md': skill('csv-clean') }),
  49. }
  50. const fetcher = async (url: string) => {
  51. if (url.startsWith('https://skills.sh/api/search')) {
  52. return new Response(JSON.stringify({ skills: [
  53. { id: 'acme/tools/csv-clean', source: 'acme/tools', skillId: 'csv-clean', name: 'csv-clean', installs: 9 },
  54. { id: 'anthropics/skills/pdf', source: 'anthropics/skills', skillId: 'pdf', name: 'pdf', installs: 1 },
  55. { id: 'bad', source: '../x', skillId: 'x' },
  56. ] }))
  57. }
  58. let m = /repos\/([^/]+\/[^/]+)\/commits\/HEAD$/.exec(url)
  59. if (m) return heads[m[1]!] ? new Response(JSON.stringify({ sha: heads[m[1]!] })) : new Response('{}', { status: 404 })
  60. m = /codeload\.github\.com\/([^/]+\/[^/]+)\/tar\.gz\/(\w+)$/.exec(url)
  61. const buf = m && repos[`${m[1]}@${m[2]}`]
  62. return buf ? new Response(buf) : new Response('nf', { status: 404 })
  63. }
  64. const audits: string[] = []
  65. const svc = new CommunitySkillService(s => audits.push(s), fetcher, roots)
  66. it('初始化插件目录:plugin.json 名称为 community-skills', () => {
  67. expect(JSON.parse(fs.readFileSync(path.join(roots.plugin, '.claude-plugin/plugin.json'), 'utf8')).name).toBe('community-skills')
  68. expect(svc.pluginRef()).toBeNull()
  69. })
  70. it('搜索:过滤非法来源,标记官方仓库', async () => {
  71. const r = await svc.search('csv')
  72. expect(r.map(s => [s.source, s.skillId, s.blocked !== null])).toEqual([['acme/tools', 'csv-clean', false], ['anthropics/skills', 'pdf', true]])
  73. expect(await svc.search('a')).toEqual([])
  74. })
  75. it('预览:定位技能目录、识别许可证与脚本、忽略符号链接', async () => {
  76. const p = await svc.preview('acme/tools', 'csv-clean')
  77. expect(p).toMatchObject({ name: 'csv-clean', path: 'skills/csv-clean', sha: SHA1, license: 'open', licenseName: 'MIT', licenseFrom: 'repo', hasScripts: true, installed: null, conflict: null })
  78. expect(p.description).toBe('csv-clean does things')
  79. expect(p.files.map(f => f.path)).toEqual(['SKILL.md', 'scripts/clean.py'])
  80. // 目录名与技能名不同时按 SKILL.md 的 name 匹配
  81. expect((await svc.preview('acme/tools', 'chart-kit')).path).toBe('skills/renamed-dir')
  82. await expect(svc.preview('acme/tools', 'missing')).rejects.toThrow(/不存在/)
  83. await expect(svc.preview('anthropics/skills', 'pdf')).rejects.toThrow(/Claude/)
  84. await expect(svc.preview('a/b; rm', 'x')).rejects.toThrow(/owner\/repo/)
  85. })
  86. it('许可证:专有许可与无法识别的许可拒绝安装;SKILL.md 声明开源许可可以安装', async () => {
  87. const secret = await svc.preview('acme/closed', 'secret')
  88. expect([secret.license, secret.licenseFrom]).toEqual(['restricted', 'skill'])
  89. expect(() => svc.install('acme/closed', 'secret', SHA1)).toThrow(/只能安装开源许可/)
  90. expect((await svc.preview('acme/closed', 'nolicense')).license).toBe('unknown')
  91. expect(() => svc.install('acme/closed', 'nolicense', SHA1)).toThrow(/无法识别/)
  92. expect(await svc.preview('acme/closed', 'declared')).toMatchObject({ license: 'open', licenseName: 'Apache-2.0', licenseFrom: 'frontmatter' })
  93. })
  94. it('安装:只复制普通文件并写入标记;必须先预览', async () => {
  95. expect(() => svc.install('acme/tools', 'csv-clean', SHA2)).toThrow(/先预览/)
  96. const list = svc.install('acme/tools', 'csv-clean', SHA1)
  97. expect(list).toMatchObject([{ id: 'community-skills:csv-clean', name: 'csv-clean', source: 'acme/tools', sha: SHA1, license: 'MIT' }])
  98. const dir = path.join(roots.plugin, 'skills/csv-clean')
  99. expect(fs.existsSync(path.join(dir, 'scripts/clean.py'))).toBe(true)
  100. expect(fs.existsSync(path.join(dir, 'passwd'))).toBe(false)
  101. expect(svc.pluginRef()).toEqual({ name: 'community-skills', path: roots.plugin, optIn: true, skills: [{ id: 'community-skills:csv-clean', loadId: 'community-skills:csv-clean' }] })
  102. expect((await svc.search('csv'))[0]!.installed).toBe(true)
  103. })
  104. it('同名冲突;仓库更新后按新提交升级;卸载', async () => {
  105. const other = await svc.preview('other/tools', 'csv-clean')
  106. expect(other.conflict).toMatch(/acme\/tools/)
  107. expect(() => svc.install('other/tools', 'csv-clean', SHA1)).toThrow(/请先卸载/)
  108. heads['acme/tools'] = SHA2
  109. const p = await svc.preview('acme/tools', 'csv-clean')
  110. expect(p).toMatchObject({ sha: SHA2, installed: { sha: SHA1 } })
  111. expect(svc.install('acme/tools', 'csv-clean', SHA2)[0]!.sha).toBe(SHA2)
  112. // 同一仓库只保留最新快照
  113. expect(fs.readdirSync(path.join(roots.cache, 'acme__tools'))).toEqual([SHA2])
  114. expect(() => svc.uninstall('../x')).toThrow()
  115. expect(svc.uninstall('csv-clean')).toEqual([])
  116. expect(audits.filter(a => a.startsWith('安装第三方技能'))).toHaveLength(2)
  117. })
  118. })
  119. describe('第三方技能需要在 Profile 中单独启用', () => {
  120. it('Claude:all 不包含 optIn 插件,显式选择时加载', () => {
  121. const plugins = [
  122. { name: 'agent-skills', path: '/a', skills: [{ id: 'agent-skills:r', loadId: 'agent-skills:r' }] },
  123. { name: 'community-skills', path: '/c', optIn: true, skills: [{ id: 'community-skills:x', loadId: 'community-skills:x' }] },
  124. ]
  125. expect(resolveSkills('all', plugins)).toEqual({ plugins: [{ type: 'local', path: '/a' }], skills: ['agent-skills:r'] })
  126. expect(resolveSkills(['community-skills:x'], plugins)).toEqual({ plugins: [{ type: 'local', path: '/c' }], skills: ['community-skills:x'] })
  127. })
  128. it('Codex:all 禁用第三方技能,显式选择时保留', () => {
  129. const all = [
  130. { name: 'agent-skills:r', path: '/a/r/SKILL.md' },
  131. { name: 'community-skills:x', path: '/c/x/SKILL.md' },
  132. // SKILL.md 的 name 不是目录名时,按目录名生成 ID
  133. { name: 'community-skills:Pretty Name', path: '/c/pretty-name/SKILL.md' },
  134. ].map(s => ({ ...s, scope: 'user' as const, description: '', enabled: true, pluginId: null }))
  135. expect(skillsToDisable(all, 'all', '/nohome', 'agent-skills').map(d => d.path)).toEqual(['/c/x/SKILL.md', '/c/pretty-name/SKILL.md'])
  136. expect(skillsToDisable(all, ['community-skills:x', 'community-skills:pretty-name'], '/nohome', 'agent-skills').map(d => d.path)).toEqual(['/a/r/SKILL.md'])
  137. })
  138. })
  139. describe('frontmatter', () => {
  140. it('支持多行写法与引号', () => {
  141. expect(frontmatter('---\nname: "a"\ndescription: |\n line1\n line2\nlicense: MIT\n---\n')).toEqual({ name: 'a', description: 'line1\nline2', license: 'MIT' })
  142. })
  143. })