|
@@ -0,0 +1,320 @@
|
|
|
|
|
+// 第三方技能(ADR-0017):在 skills.sh 目录中搜索,从 GitHub 按固定提交下载,安装到第三方技能目录。
|
|
|
|
|
+// 目录采用 Claude 插件格式(.claude-plugin/plugin.json + skills/<name>),Claude 按路径加载为插件,
|
|
|
|
|
+// Codex 通过 skills/extraRoots/set 加入;两个引擎显示的技能 ID 都是 community-skills:<name>。
|
|
|
|
|
+// 只允许开源许可;安装前必须先预览 SKILL.md;默认不启用,需要在 Profile 中单独勾选。
|
|
|
|
|
+import { execFile } from 'node:child_process'
|
|
|
|
|
+import fs from 'node:fs'
|
|
|
|
|
+import path from 'node:path'
|
|
|
|
|
+import { promisify } from 'node:util'
|
|
|
|
|
+import {
|
|
|
|
|
+ COMMUNITY_SKILL_PLUGIN, type CommunitySearchResultDto, type CommunitySkillDto, type CommunitySkillPreviewDto, type LicenseKind, type SkillInfo,
|
|
|
|
|
+} from '@agent-server/shared'
|
|
|
|
|
+import { communitySkillsDir, config } from '../config'
|
|
|
|
|
+import { AppError, badRequest, notFound } from '../lib/errors'
|
|
|
|
|
+import { frontmatter } from '../lib/skill-files'
|
|
|
|
|
+import type { PluginRef } from '../providers/claude/skills'
|
|
|
|
|
+
|
|
|
|
|
+const execFileP = promisify(execFile)
|
|
|
|
|
+
|
|
|
|
|
+const MARKER = '.agent-server.json'
|
|
|
|
|
+const MAX_TARBALL = 50 * 1048576
|
|
|
|
|
+const MAX_SKILL_FILES = 500
|
|
|
|
|
+const MAX_SKILL_BYTES = 20 * 1048576
|
|
|
|
|
+const MAX_SKILL_MD = 200_000
|
|
|
|
|
+const SEARCH_DEPTH = 6
|
|
|
|
|
+const SKIP_DIRS = new Set(['.git', 'node_modules', '.github'])
|
|
|
|
|
+const SCRIPT_EXT = /\.(py|sh|bash|js|mjs|cjs|ts|rb|pl|ps1|exe|bin)$/i
|
|
|
|
|
+/** 官方技能仓库按引擎在各自的页面安装,不作为第三方技能(官方技能不交叉使用,ADR-0016) */
|
|
|
|
|
+const BLOCKED_SOURCES: Record<string, string> = {
|
|
|
|
|
+ 'anthropics/skills': 'Anthropic 官方技能请在 "Claude · Anthropic 官方技能" 中安装,只提供给 Claude',
|
|
|
|
|
+ 'openai/skills': 'OpenAI 官方技能请在 "Codex · OpenAI 官方技能" 中安装,只提供给 Codex',
|
|
|
|
|
+}
|
|
|
|
|
+/** GitHub owner/repo;不允许以 . 开头(防止 ../ 之类的路径) */
|
|
|
|
|
+const REPO_RE = /^[\w-][\w.-]{0,99}\/[\w-][\w.-]{0,99}$/
|
|
|
|
|
+const NAME_RE = /^[a-z0-9][\w.-]{0,63}$/i
|
|
|
|
|
+
|
|
|
|
|
+interface Marker { source: string; skillId: string; path: string; sha: string; installedAt: string; license: string | null }
|
|
|
|
|
+
|
|
|
|
|
+type Fetcher = (url: string) => Promise<Response>
|
|
|
|
|
+
|
|
|
|
|
+export class CommunitySkillService {
|
|
|
|
|
+ constructor(
|
|
|
|
|
+ private audit: (summary: string) => void,
|
|
|
|
|
+ private fetcher: Fetcher = (url: string) => fetch(url, { headers: { 'user-agent': 'agent-server', accept: 'application/json' }, signal: AbortSignal.timeout(120_000) }),
|
|
|
|
|
+ private roots = { cache: path.join(config.dataDir, 'data', 'skill-catalogs', 'github'), plugin: communitySkillsDir() },
|
|
|
|
|
+ ) {
|
|
|
|
|
+ this.ensurePlugin()
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private get skillsRoot() {
|
|
|
|
|
+ return path.join(this.roots.plugin, 'skills')
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** 插件清单:Claude 用它作为技能前缀,Codex 也据此给 extraRoots 中的技能加前缀 */
|
|
|
|
|
+ private ensurePlugin() {
|
|
|
|
|
+ fs.mkdirSync(this.skillsRoot, { recursive: true, mode: 0o755 })
|
|
|
|
|
+ const manifest = path.join(this.roots.plugin, '.claude-plugin', 'plugin.json')
|
|
|
|
|
+ if (!fs.existsSync(manifest)) {
|
|
|
|
|
+ fs.mkdirSync(path.dirname(manifest), { recursive: true })
|
|
|
|
|
+ fs.writeFileSync(manifest, JSON.stringify({ name: COMMUNITY_SKILL_PLUGIN, version: '0.1.0', description: '第三方技能(由 AgentServer 管理)' }, null, 2))
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ async search(q: string): Promise<CommunitySearchResultDto[]> {
|
|
|
|
|
+ const query = q.trim().slice(0, 100)
|
|
|
|
|
+ if (query.length < 2) return []
|
|
|
|
|
+ const r = await this.fetcher(`https://skills.sh/api/search?q=${encodeURIComponent(query)}`)
|
|
|
|
|
+ if (!r.ok) throw new AppError(502, 'search_failed', `skills.sh 搜索失败(HTTP ${r.status})`)
|
|
|
|
|
+ const body = (await r.json()) as { skills?: { source?: string; skillId?: string; name?: string; installs?: number }[] }
|
|
|
|
|
+ const installed = this.installedMarkers()
|
|
|
|
|
+ return (body.skills ?? [])
|
|
|
|
|
+ .filter((s): s is { source: string; skillId: string; name?: string; installs?: number } => typeof s.source === 'string' && REPO_RE.test(s.source) && typeof s.skillId === 'string')
|
|
|
|
|
+ .map(s => ({
|
|
|
|
|
+ source: s.source, skillId: s.skillId, name: s.name ?? s.skillId, installs: s.installs ?? 0,
|
|
|
|
|
+ installed: [...installed.values()].some(m => m.source === s.source && m.skillId === s.skillId),
|
|
|
|
|
+ blocked: BLOCKED_SOURCES[s.source.toLowerCase()] ?? null,
|
|
|
|
|
+ }))
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** 下载仓库当前版本并定位技能,返回安装前必须查看的内容 */
|
|
|
|
|
+ async preview(source: string, skillId: string): Promise<CommunitySkillPreviewDto> {
|
|
|
|
|
+ const { sha, dir } = await this.snapshot(source)
|
|
|
|
|
+ return this.describe(source, skillId, sha, dir)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** 按预览时的提交安装(预览后仓库有新提交也不会装上未看过的内容) */
|
|
|
|
|
+ install(source: string, skillId: string, sha: string): CommunitySkillDto[] {
|
|
|
|
|
+ checkSource(source)
|
|
|
|
|
+ if (!/^[0-9a-f]{40}$/.test(sha)) throw badRequest('提交号格式不正确')
|
|
|
|
|
+ const dir = this.snapshotDir(source, sha)
|
|
|
|
|
+ if (!fs.existsSync(dir)) throw badRequest('请先预览技能内容再安装')
|
|
|
|
|
+ const p = this.describe(source, skillId, sha, dir)
|
|
|
|
|
+ if (p.license !== 'open') throw badRequest(`技能 ${skillId} 的许可为"${p.licenseName ?? '无法识别'}",只能安装开源许可的技能`)
|
|
|
|
|
+ if (p.conflict) throw badRequest(p.conflict)
|
|
|
|
|
+ const src = path.join(dir, p.path)
|
|
|
|
|
+ const dest = path.join(this.skillsRoot, p.name)
|
|
|
|
|
+ const tmp = `${dest}.installing`
|
|
|
|
|
+ fs.rmSync(tmp, { recursive: true, force: true })
|
|
|
|
|
+ copyRegularFiles(src, tmp)
|
|
|
|
|
+ const marker: Marker = { source, skillId, path: p.path, sha, installedAt: new Date().toISOString(), license: p.licenseName }
|
|
|
|
|
+ fs.writeFileSync(path.join(tmp, MARKER), JSON.stringify(marker, null, 2))
|
|
|
|
|
+ fs.rmSync(dest, { recursive: true, force: true })
|
|
|
|
|
+ fs.renameSync(tmp, dest)
|
|
|
|
|
+ this.audit(`安装第三方技能 ${p.name}(${source}@${sha.slice(0, 8)}:${p.path},${p.licenseName})`)
|
|
|
|
|
+ return this.list()
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ uninstall(name: string): CommunitySkillDto[] {
|
|
|
|
|
+ const dest = path.join(this.skillsRoot, name)
|
|
|
|
|
+ if (!NAME_RE.test(name) || !fs.existsSync(path.join(dest, MARKER))) throw notFound(`第三方技能 ${name}`)
|
|
|
|
|
+ fs.rmSync(dest, { recursive: true, force: true })
|
|
|
|
|
+ this.audit(`卸载第三方技能 ${name}`)
|
|
|
|
|
+ return this.list()
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ list(): CommunitySkillDto[] {
|
|
|
|
|
+ return [...this.installedMarkers()].map(([name, m]) => {
|
|
|
|
|
+ const fm = frontmatter(readText(path.join(this.skillsRoot, name, 'SKILL.md')) ?? '')
|
|
|
|
|
+ return {
|
|
|
|
|
+ id: `${COMMUNITY_SKILL_PLUGIN}:${name}`, name, description: fm.description ?? '', source: m.source, skillId: m.skillId,
|
|
|
|
|
+ path: m.path, sha: m.sha, license: m.license, installedAt: m.installedAt,
|
|
|
|
|
+ }
|
|
|
|
|
+ }).sort((a, b) => a.name.localeCompare(b.name))
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ skillInfos(): SkillInfo[] {
|
|
|
|
|
+ return this.list().map(s => ({ id: s.id, name: s.name, description: s.description, plugin: COMMUNITY_SKILL_PLUGIN, source: 'community', license: 'open' }))
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** Claude 运行时按路径加载;optIn 表示 Profile 选"全部技能"时不包含 */
|
|
|
|
|
+ pluginRef(): PluginRef | null {
|
|
|
|
|
+ const names = [...this.installedMarkers().keys()]
|
|
|
|
|
+ if (!names.length) return null
|
|
|
|
|
+ const skills = names.map(n => ({ id: `${COMMUNITY_SKILL_PLUGIN}:${n}`, loadId: `${COMMUNITY_SKILL_PLUGIN}:${n}` }))
|
|
|
|
|
+ return { name: COMMUNITY_SKILL_PLUGIN, path: this.roots.plugin, skills, optIn: true }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ---- 内部 ----
|
|
|
|
|
+
|
|
|
|
|
+ private snapshotDir(source: string, sha: string) {
|
|
|
|
|
+ return path.join(this.roots.cache, source.replace('/', '__'), sha)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** 取默认分支最新提交并缓存仓库快照;同一仓库只保留最近一次的快照 */
|
|
|
|
|
+ private async snapshot(source: string): Promise<{ sha: string; dir: string }> {
|
|
|
|
|
+ checkSource(source)
|
|
|
|
|
+ const r = await this.fetcher(`https://api.github.com/repos/${source}/commits/HEAD`)
|
|
|
|
|
+ if (r.status === 404) throw notFound(`GitHub 仓库 ${source}`)
|
|
|
|
|
+ if (!r.ok) throw new AppError(502, 'fetch_failed', `无法获取 ${source} 的最新版本(HTTP ${r.status})`)
|
|
|
|
|
+ const sha = ((await r.json()) as { sha?: string }).sha ?? ''
|
|
|
|
|
+ if (!/^[0-9a-f]{40}$/.test(sha)) throw new AppError(502, 'fetch_failed', 'GitHub 返回的提交号格式不正确')
|
|
|
|
|
+ const dir = this.snapshotDir(source, sha)
|
|
|
|
|
+ if (fs.existsSync(dir)) return { sha, dir }
|
|
|
|
|
+
|
|
|
|
|
+ const tar = await this.fetcher(`https://codeload.github.com/${source}/tar.gz/${sha}`)
|
|
|
|
|
+ if (!tar.ok) throw new AppError(502, 'fetch_failed', `下载仓库失败(HTTP ${tar.status})`)
|
|
|
|
|
+ const len = Number(tar.headers.get('content-length') ?? 0)
|
|
|
|
|
+ if (len > MAX_TARBALL) throw badRequest(`仓库压缩包超过 ${MAX_TARBALL / 1048576} MB,不支持`)
|
|
|
|
|
+ const buf = Buffer.from(await tar.arrayBuffer())
|
|
|
|
|
+ if (buf.length > MAX_TARBALL) throw badRequest(`仓库压缩包超过 ${MAX_TARBALL / 1048576} MB,不支持`)
|
|
|
|
|
+ const repoDir = path.dirname(dir)
|
|
|
|
|
+ fs.rmSync(repoDir, { recursive: true, force: true })
|
|
|
|
|
+ const tmp = `${dir}.extracting`
|
|
|
|
|
+ fs.mkdirSync(tmp, { recursive: true })
|
|
|
|
|
+ const file = path.join(repoDir, `${sha}.tar.gz`)
|
|
|
|
|
+ fs.writeFileSync(file, buf)
|
|
|
|
|
+ try {
|
|
|
|
|
+ await execFileP('tar', ['-xzf', file, '-C', tmp, '--strip-components=1', '--no-same-owner', '--no-same-permissions'])
|
|
|
|
|
+ fs.renameSync(tmp, dir)
|
|
|
|
|
+ } catch (err) {
|
|
|
|
|
+ fs.rmSync(tmp, { recursive: true, force: true })
|
|
|
|
|
+ throw new AppError(502, 'fetch_failed', `解压仓库失败:${(err as Error).message.slice(0, 200)}`)
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ fs.rmSync(file, { force: true })
|
|
|
|
|
+ }
|
|
|
|
|
+ return { sha, dir }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private describe(source: string, skillId: string, sha: string, dir: string): CommunitySkillPreviewDto {
|
|
|
|
|
+ const rel = findSkill(dir, skillId)
|
|
|
|
|
+ if (rel === null) throw notFound(`仓库 ${source} 中的技能 ${skillId}`)
|
|
|
|
|
+ const skillDir = path.join(dir, rel)
|
|
|
|
|
+ const skillMd = readText(path.join(skillDir, 'SKILL.md'))?.slice(0, MAX_SKILL_MD) ?? ''
|
|
|
|
|
+ const fm = frontmatter(skillMd)
|
|
|
|
|
+ const name = fm.name && NAME_RE.test(fm.name) ? fm.name : skillId
|
|
|
|
|
+ if (!NAME_RE.test(name)) throw badRequest(`技能名称 ${name} 格式不正确`)
|
|
|
|
|
+ const files = listFiles(skillDir)
|
|
|
|
|
+ const lic = detectLicense(skillDir, fm.license, dir)
|
|
|
|
|
+ const installed = this.installedMarkers().get(name)
|
|
|
|
|
+ const sameSource = installed && installed.source === source && installed.skillId === skillId
|
|
|
|
|
+ const unmanaged = !installed && fs.existsSync(path.join(this.skillsRoot, name))
|
|
|
|
|
+ return {
|
|
|
|
|
+ source, skillId, name, description: fm.description ?? '', sha, path: rel, skillMd, files,
|
|
|
|
|
+ hasScripts: files.some(f => SCRIPT_EXT.test(f.path)),
|
|
|
|
|
+ license: lic.kind, licenseName: lic.name, licenseFrom: lic.from,
|
|
|
|
|
+ installed: sameSource ? { sha: installed.sha } : null,
|
|
|
|
|
+ conflict: installed && !sameSource
|
|
|
|
|
+ ? `已经从 ${installed.source} 安装了同名技能 ${name},请先卸载`
|
|
|
|
|
+ : unmanaged ? `第三方技能目录中已有 ${name},且不是由 AgentServer 安装的` : null,
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private installedMarkers(): Map<string, Marker> {
|
|
|
|
|
+ const out = new Map<string, Marker>()
|
|
|
|
|
+ for (const d of safeEntries(this.skillsRoot)) {
|
|
|
|
|
+ if (!d.isDirectory() || d.name.endsWith('.installing')) continue
|
|
|
|
|
+ try {
|
|
|
|
|
+ out.set(d.name, JSON.parse(fs.readFileSync(path.join(this.skillsRoot, d.name, MARKER), 'utf8')) as Marker)
|
|
|
|
|
+ } catch {
|
|
|
|
|
+ // 不是由 AgentServer 安装的
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ return out
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function checkSource(source: string) {
|
|
|
|
|
+ if (!REPO_RE.test(source)) throw badRequest('仓库格式应为 owner/repo')
|
|
|
|
|
+ const blocked = BLOCKED_SOURCES[source.toLowerCase()]
|
|
|
|
|
+ if (blocked) throw badRequest(blocked)
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * 在仓库中定位技能目录(返回相对路径,根目录为 '')。
|
|
|
|
|
+ * 优先 SKILL.md 中 name 与 skillId 相同的目录,其次目录名相同;多个匹配时取路径最短的。
|
|
|
|
|
+ */
|
|
|
|
|
+function findSkill(root: string, skillId: string): string | null {
|
|
|
|
|
+ const byName: string[] = []
|
|
|
|
|
+ const byDir: string[] = []
|
|
|
|
|
+ const walk = (dir: string, depth: number) => {
|
|
|
|
|
+ const entries = safeEntries(dir)
|
|
|
|
|
+ const md = entries.find(e => e.name === 'SKILL.md' && e.isFile())
|
|
|
|
|
+ if (md) {
|
|
|
|
|
+ const rel = path.relative(root, dir)
|
|
|
|
|
+ const fm = frontmatter(readText(path.join(dir, 'SKILL.md'))?.slice(0, 4000) ?? '')
|
|
|
|
|
+ if (fm.name === skillId) byName.push(rel)
|
|
|
|
|
+ else if (path.basename(dir) === skillId) byDir.push(rel)
|
|
|
|
|
+ }
|
|
|
|
|
+ if (depth >= SEARCH_DEPTH) return
|
|
|
|
|
+ for (const e of entries) if (e.isDirectory() && !SKIP_DIRS.has(e.name)) walk(path.join(dir, e.name), depth + 1)
|
|
|
|
|
+ }
|
|
|
|
|
+ walk(root, 0)
|
|
|
|
|
+ const pick = (l: string[]) => l.sort((a, b) => a.split(path.sep).length - b.split(path.sep).length || a.localeCompare(b))[0]
|
|
|
|
|
+ return pick(byName) ?? pick(byDir) ?? null
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/** 技能目录中的普通文件(跳过符号链接等特殊文件) */
|
|
|
|
|
+function listFiles(dir: string): { path: string; size: number }[] {
|
|
|
|
|
+ const out: { path: string; size: number }[] = []
|
|
|
|
|
+ const walk = (d: string) => {
|
|
|
|
|
+ for (const e of safeEntries(d)) {
|
|
|
|
|
+ const abs = path.join(d, e.name)
|
|
|
|
|
+ if (e.isDirectory()) {
|
|
|
|
|
+ if (!SKIP_DIRS.has(e.name)) walk(abs)
|
|
|
|
|
+ } else if (e.isFile()) {
|
|
|
|
|
+ out.push({ path: path.relative(dir, abs), size: fs.statSync(abs).size })
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ walk(dir)
|
|
|
|
|
+ // SKILL.md 排在最前
|
|
|
|
|
+ return out.sort((a, b) => Number(b.path === 'SKILL.md') - Number(a.path === 'SKILL.md') || a.path.localeCompare(b.path))
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/** 只复制普通文件和目录:仓库中的符号链接可能指向服务器上的其他文件 */
|
|
|
|
|
+function copyRegularFiles(src: string, dest: string) {
|
|
|
|
|
+ const files = listFiles(src)
|
|
|
|
|
+ if (files.length > MAX_SKILL_FILES) throw badRequest(`技能包含 ${files.length} 个文件,超过 ${MAX_SKILL_FILES} 个的上限`)
|
|
|
|
|
+ const total = files.reduce((n, f) => n + f.size, 0)
|
|
|
|
|
+ if (total > MAX_SKILL_BYTES) throw badRequest(`技能大小超过 ${MAX_SKILL_BYTES / 1048576} MB 上限`)
|
|
|
|
|
+ for (const f of files) {
|
|
|
|
|
+ const to = path.join(dest, f.path)
|
|
|
|
|
+ fs.mkdirSync(path.dirname(to), { recursive: true })
|
|
|
|
|
+ fs.copyFileSync(path.join(src, f.path), to)
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+const OPEN_SPDX = /^(MIT|MIT-0|Apache[- ]?(License[- ])?2(\.0)?|BSD-[23]-Clause|0BSD|ISC|MPL-2\.0|Unlicense|CC0-1\.0|CC-BY-4\.0)$/i
|
|
|
|
|
+
|
|
|
|
|
+/** 许可证:技能目录中的 LICENSE 优先,其次 SKILL.md 的 license 字段,最后是仓库根目录的 LICENSE */
|
|
|
|
|
+function detectLicense(skillDir: string, declared: string | undefined, repoDir: string): { kind: LicenseKind; name: string | null; from: CommunitySkillPreviewDto['licenseFrom'] } {
|
|
|
|
|
+ const own = licenseFile(skillDir)
|
|
|
|
|
+ if (own) return { ...own, from: 'skill' }
|
|
|
|
|
+ if (declared && OPEN_SPDX.test(declared.trim())) return { kind: 'open', name: declared.trim(), from: 'frontmatter' }
|
|
|
|
|
+ if (declared && /proprietary|all rights reserved/i.test(declared)) return { kind: 'restricted', name: '专有许可', from: 'frontmatter' }
|
|
|
|
|
+ const repo = skillDir === repoDir ? null : licenseFile(repoDir)
|
|
|
|
|
+ if (repo) return { ...repo, from: 'repo' }
|
|
|
|
|
+ return { kind: 'unknown', name: declared ? declared.slice(0, 60) : null, from: declared ? 'frontmatter' : null }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function licenseFile(dir: string): { kind: LicenseKind; name: string | null } | null {
|
|
|
|
|
+ const f = safeEntries(dir).find(e => e.isFile() && /^(licen[cs]e|copying)(\.|$)/i.test(e.name))
|
|
|
|
|
+ if (!f) return null
|
|
|
|
|
+ const text = readText(path.join(dir, f.name))?.slice(0, 6000) ?? ''
|
|
|
|
|
+ if (/agreement\s+with\s+Anthropic|proprietary|internal evaluation|governed by/i.test(text)) return { kind: 'restricted', name: '专有许可或第三方条款' }
|
|
|
|
|
+ if (/Apache License/i.test(text)) return { kind: 'open', name: 'Apache-2.0' }
|
|
|
|
|
+ if (/MIT License|Permission is hereby granted, free of charge/i.test(text)) return { kind: 'open', name: 'MIT' }
|
|
|
|
|
+ if (/Redistribution and use in source and binary forms/i.test(text)) return { kind: 'open', name: 'BSD' }
|
|
|
|
|
+ if (/ISC License|Permission to use, copy, modify, and\/or distribute/i.test(text)) return { kind: 'open', name: 'ISC' }
|
|
|
|
|
+ if (/Mozilla Public License/i.test(text)) return { kind: 'open', name: 'MPL-2.0' }
|
|
|
|
|
+ if (/free and unencumbered software/i.test(text)) return { kind: 'open', name: 'Unlicense' }
|
|
|
|
|
+ if (/CC0 1\.0|Creative Commons Zero/i.test(text)) return { kind: 'open', name: 'CC0-1.0' }
|
|
|
|
|
+ return { kind: 'unknown', name: null }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function safeEntries(dir: string): fs.Dirent[] {
|
|
|
|
|
+ try {
|
|
|
|
|
+ return fs.readdirSync(dir, { withFileTypes: true })
|
|
|
|
|
+ } catch {
|
|
|
|
|
+ return []
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function readText(f: string): string | null {
|
|
|
|
|
+ try {
|
|
|
|
|
+ // 只读普通文件,不跟随符号链接
|
|
|
|
|
+ return fs.lstatSync(f).isFile() ? fs.readFileSync(f, 'utf8') : null
|
|
|
|
|
+ } catch {
|
|
|
|
|
+ return null
|
|
|
|
|
+ }
|
|
|
|
|
+}
|