|
|
@@ -0,0 +1,208 @@
|
|
|
+// Codex 官方技能:来自 OpenAI 官方仓库 github.com/openai/skills(ADR-0016)。
|
|
|
+// 目录 = 仓库在某个提交上的快照(缓存在数据目录);安装 = 把技能复制到服务专用 CODEX_HOME/skills/<name>,
|
|
|
+// 并写入标记文件记录来源、提交与许可证。Codex 会自动加载 CODEX_HOME/skills 下的技能。
|
|
|
+import { execFile } from 'node:child_process'
|
|
|
+import fs from 'node:fs'
|
|
|
+import path from 'node:path'
|
|
|
+import { promisify } from 'node:util'
|
|
|
+import type { LicenseKind } from '@agent-server/shared'
|
|
|
+import { config } from '../config'
|
|
|
+import { AppError, badRequest, notFound } from '../lib/errors'
|
|
|
+import { codexHome } from '../providers/codex/cli'
|
|
|
+
|
|
|
+const execFileP = promisify(execFile)
|
|
|
+
|
|
|
+export const OPENAI_SKILLS_REPO = 'openai/skills'
|
|
|
+const MARKER = '.agent-server.json'
|
|
|
+const CURATED = 'skills/.curated'
|
|
|
+
|
|
|
+export interface CodexCatalogSkill {
|
|
|
+ name: string
|
|
|
+ description: string
|
|
|
+ license: LicenseKind
|
|
|
+ licenseName: string | null
|
|
|
+ installed: boolean
|
|
|
+ installedSha: string | null
|
|
|
+ /** 已安装版本与当前目录快照不同 */
|
|
|
+ updatable: boolean
|
|
|
+}
|
|
|
+
|
|
|
+export interface CodexSkillCatalog {
|
|
|
+ repo: string
|
|
|
+ sha: string | null
|
|
|
+ fetchedAt: string | null
|
|
|
+ skills: CodexCatalogSkill[]
|
|
|
+ /** CODEX_HOME/skills 下不是由 AgentServer 安装的技能(只展示,不管理) */
|
|
|
+ unmanaged: string[]
|
|
|
+}
|
|
|
+
|
|
|
+interface Marker { source: string; path: string; sha: string; installedAt: string; license: string | null }
|
|
|
+
|
|
|
+type Fetcher = (url: string) => Promise<Response>
|
|
|
+
|
|
|
+export class CodexSkillService {
|
|
|
+ constructor(
|
|
|
+ private audit: (summary: string) => void,
|
|
|
+ private fetcher: Fetcher = (url: string) => fetch(url, { headers: { 'user-agent': 'agent-server', accept: 'application/vnd.github+json' } }),
|
|
|
+ private roots = { catalog: path.join(config.dataDir, 'data', 'skill-catalogs', 'openai-skills'), skills: path.join(codexHome(), 'skills') },
|
|
|
+ ) {}
|
|
|
+
|
|
|
+ private get currentFile() {
|
|
|
+ return path.join(this.roots.catalog, 'current.json')
|
|
|
+ }
|
|
|
+
|
|
|
+ private current(): { sha: string; fetchedAt: string } | null {
|
|
|
+ try {
|
|
|
+ return JSON.parse(fs.readFileSync(this.currentFile, 'utf8')) as { sha: string; fetchedAt: string }
|
|
|
+ } catch {
|
|
|
+ return null
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 拉取官方仓库 main 分支的最新提交并缓存快照 */
|
|
|
+ async refresh(): Promise<CodexSkillCatalog> {
|
|
|
+ const r = await this.fetcher(`https://api.github.com/repos/${OPENAI_SKILLS_REPO}/commits/main`)
|
|
|
+ if (!r.ok) throw new AppError(502, 'catalog_fetch_failed', `无法获取 ${OPENAI_SKILLS_REPO} 的最新版本(HTTP ${r.status})`)
|
|
|
+ const sha = ((await r.json()) as { sha: string }).sha
|
|
|
+ if (!/^[0-9a-f]{40}$/.test(sha)) throw new AppError(502, 'catalog_fetch_failed', '官方仓库返回的提交号格式不正确')
|
|
|
+ const dir = path.join(this.roots.catalog, sha)
|
|
|
+ if (!fs.existsSync(path.join(dir, CURATED))) {
|
|
|
+ const tar = await this.fetcher(`https://codeload.github.com/${OPENAI_SKILLS_REPO}/tar.gz/${sha}`)
|
|
|
+ if (!tar.ok) throw new AppError(502, 'catalog_fetch_failed', `下载技能仓库失败(HTTP ${tar.status})`)
|
|
|
+ fs.mkdirSync(dir, { recursive: true })
|
|
|
+ const file = path.join(this.roots.catalog, `${sha}.tar.gz`)
|
|
|
+ fs.writeFileSync(file, Buffer.from(await tar.arrayBuffer()))
|
|
|
+ try {
|
|
|
+ // 只解压技能目录;--strip-components 去掉 GitHub 打包时加的顶层目录
|
|
|
+ await execFileP('tar', ['-xzf', file, '-C', dir, '--strip-components=1', '--no-same-owner', '--wildcards', '*/skills/.curated/*', '*/skills/.system/*'])
|
|
|
+ } finally {
|
|
|
+ fs.rmSync(file, { force: true })
|
|
|
+ }
|
|
|
+ }
|
|
|
+ fs.writeFileSync(this.currentFile, JSON.stringify({ sha, fetchedAt: new Date().toISOString() }))
|
|
|
+ this.audit(`更新 Codex 官方技能目录:${OPENAI_SKILLS_REPO}@${sha.slice(0, 8)}`)
|
|
|
+ return this.catalog()
|
|
|
+ }
|
|
|
+
|
|
|
+ catalog(): CodexSkillCatalog {
|
|
|
+ const cur = this.current()
|
|
|
+ const installed = this.installedMarkers()
|
|
|
+ const skills: CodexCatalogSkill[] = []
|
|
|
+ if (cur) {
|
|
|
+ const base = path.join(this.roots.catalog, cur.sha, CURATED)
|
|
|
+ for (const name of safeDirs(base)) {
|
|
|
+ const md = readText(path.join(base, name, 'SKILL.md'))
|
|
|
+ if (!md) continue
|
|
|
+ const fm = frontmatter(md)
|
|
|
+ const lic = detectLicense(path.join(base, name))
|
|
|
+ const m = installed.get(name)
|
|
|
+ skills.push({
|
|
|
+ name, description: fm.description ?? '', license: lic.kind, licenseName: lic.name,
|
|
|
+ installed: !!m, installedSha: m?.sha ?? null, updatable: !!m && m.sha !== cur.sha,
|
|
|
+ })
|
|
|
+ }
|
|
|
+ }
|
|
|
+ // 已安装但不在当前目录快照中的技能也要列出,便于卸载
|
|
|
+ for (const [name, m] of installed) {
|
|
|
+ if (!skills.some(s => s.name === name)) {
|
|
|
+ skills.push({ name, description: '(当前目录中已不存在)', license: 'unknown', licenseName: m.license, installed: true, installedSha: m.sha, updatable: false })
|
|
|
+ }
|
|
|
+ }
|
|
|
+ const unmanaged = safeDirs(this.roots.skills).filter(n => !installed.has(n))
|
|
|
+ return { repo: OPENAI_SKILLS_REPO, sha: cur?.sha ?? null, fetchedAt: cur?.fetchedAt ?? null, skills: skills.sort((a, b) => a.name.localeCompare(b.name)), unmanaged }
|
|
|
+ }
|
|
|
+
|
|
|
+ install(name: string): CodexSkillCatalog {
|
|
|
+ const cur = this.current()
|
|
|
+ if (!cur) throw badRequest('请先更新技能目录')
|
|
|
+ if (!/^[\w.-]+$/.test(name)) throw badRequest('技能名称格式不正确')
|
|
|
+ const src = path.join(this.roots.catalog, cur.sha, CURATED, name)
|
|
|
+ if (!fs.existsSync(path.join(src, 'SKILL.md'))) throw notFound(`官方技能 ${name}`)
|
|
|
+ const dest = path.join(this.roots.skills, name)
|
|
|
+ if (fs.existsSync(dest) && !fs.existsSync(path.join(dest, MARKER))) throw badRequest(`CODEX_HOME/skills/${name} 已存在且不是由 AgentServer 安装的,请先手动处理`)
|
|
|
+ // 只允许安装开源许可(Apache-2.0 / MIT)的技能;专有许可、第三方条款、无法识别的许可都拒绝
|
|
|
+ const lic = detectLicense(src)
|
|
|
+ if (lic.kind !== 'open') throw badRequest(`技能 ${name} 的许可为"${lic.name ?? '无法识别'}",只能安装开源许可(Apache-2.0 / MIT)的技能`)
|
|
|
+ fs.mkdirSync(this.roots.skills, { recursive: true, mode: 0o700 })
|
|
|
+ const tmp = `${dest}.installing`
|
|
|
+ fs.rmSync(tmp, { recursive: true, force: true })
|
|
|
+ fs.cpSync(src, tmp, { recursive: true })
|
|
|
+ const marker: Marker = { source: OPENAI_SKILLS_REPO, path: `${CURATED}/${name}`, sha: cur.sha, installedAt: new Date().toISOString(), license: lic.name }
|
|
|
+ fs.writeFileSync(path.join(tmp, MARKER), JSON.stringify(marker, null, 2))
|
|
|
+ fs.rmSync(dest, { recursive: true, force: true })
|
|
|
+ fs.renameSync(tmp, dest)
|
|
|
+ this.audit(`安装 Codex 官方技能 ${name}(${OPENAI_SKILLS_REPO}@${cur.sha.slice(0, 8)},${lic.name ?? '许可证未知'})`)
|
|
|
+ return this.catalog()
|
|
|
+ }
|
|
|
+
|
|
|
+ uninstall(name: string): CodexSkillCatalog {
|
|
|
+ const dest = path.join(this.roots.skills, name)
|
|
|
+ if (!/^[\w.-]+$/.test(name) || !fs.existsSync(path.join(dest, MARKER))) throw notFound(`由 AgentServer 安装的技能 ${name}`)
|
|
|
+ fs.rmSync(dest, { recursive: true, force: true })
|
|
|
+ this.audit(`卸载 Codex 官方技能 ${name}`)
|
|
|
+ return this.catalog()
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 已安装技能的名称(用于 Profile 的技能 ID:openai-skills:<name>) */
|
|
|
+ installedNames(): string[] {
|
|
|
+ return [...this.installedMarkers().keys()]
|
|
|
+ }
|
|
|
+
|
|
|
+ private installedMarkers(): Map<string, Marker> {
|
|
|
+ const out = new Map<string, Marker>()
|
|
|
+ for (const name of safeDirs(this.roots.skills)) {
|
|
|
+ try {
|
|
|
+ out.set(name, JSON.parse(fs.readFileSync(path.join(this.roots.skills, name, MARKER), 'utf8')) as Marker)
|
|
|
+ } catch {
|
|
|
+ // 不是由 AgentServer 安装的
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return out
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function safeDirs(dir: string): string[] {
|
|
|
+ try {
|
|
|
+ return fs.readdirSync(dir, { withFileTypes: true }).filter(d => d.isDirectory() && !d.name.startsWith('.') && !d.name.endsWith('.installing')).map(d => d.name)
|
|
|
+ } catch {
|
|
|
+ return []
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function readText(f: string): string | null {
|
|
|
+ try {
|
|
|
+ return fs.readFileSync(f, 'utf8')
|
|
|
+ } catch {
|
|
|
+ return null
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function frontmatter(md: string): Record<string, string> {
|
|
|
+ const m = /^---\r?\n([\s\S]*?)\r?\n---/.exec(md)
|
|
|
+ const out: Record<string, string> = {}
|
|
|
+ for (const line of m?.[1]?.split(/\r?\n/) ?? []) {
|
|
|
+ const kv = /^([A-Za-z_-]+):\s*(.*)$/.exec(line)
|
|
|
+ if (kv) out[kv[1]!] = kv[2]!.replace(/^["']|["']$/g, '').trim()
|
|
|
+ }
|
|
|
+ return out
|
|
|
+}
|
|
|
+
|
|
|
+function detectLicense(dir: string): { kind: LicenseKind; name: string | null } {
|
|
|
+ const f = safeFiles(dir).find(n => /^licen[cs]e/i.test(n))
|
|
|
+ const text = f ? readText(path.join(dir, f))?.slice(0, 4000) ?? '' : ''
|
|
|
+ if (!text) return { kind: 'unknown', name: null }
|
|
|
+ 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 (/proprietary|All rights reserved|internal evaluation/i.test(text)) return { kind: 'restricted', name: '专有许可' }
|
|
|
+ // 例如 Figma 技能:"governed by the Figma Developer Terms"
|
|
|
+ if (/governed by/i.test(text)) return { kind: 'restricted', name: '第三方条款' }
|
|
|
+ return { kind: 'unknown', name: null }
|
|
|
+}
|
|
|
+
|
|
|
+function safeFiles(dir: string): string[] {
|
|
|
+ try {
|
|
|
+ return fs.readdirSync(dir)
|
|
|
+ } catch {
|
|
|
+ return []
|
|
|
+ }
|
|
|
+}
|