项目文件夹

文件
2026-07-13 13:11:28 +08:00

315 行
8.6 KiB
JavaScript
被供应的

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
import { checkbox } from '@inquirer/prompts'
import { openSync, promises as fs } from 'fs'
import path from 'path'
import { ReadStream, WriteStream } from 'node:tty'
import { styleText } from 'node:util'
import { fileURLToPath } from 'url'
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
const repoRoot = path.resolve(__dirname, '..')
const skillsRoot = path.join(repoRoot, 'skills')
async function main() {
const shouldSkip = process.env.HULA_SKIP_SKILL_LINK || process.env.CI
const isLifecycle = Boolean(process.env.npm_lifecycle_event)
const canPrompt = Boolean(process.stdin.isTTY && process.stdout.isTTY)
const forcePrompt = process.env.HULA_FORCE_SKILL_LINK === '1'
if (shouldSkip) {
console.log('已跳过技能链接:检测到 HULA_SKIP_SKILL_LINK 或 CI。')
return
}
const homeDir = process.env.HOME || process.env.USERPROFILE
if (!homeDir) {
console.log('已跳过技能链接:未检测到 HOME 或 USERPROFILE。')
return
}
let skillDirs = []
try {
const entries = await fs.readdir(skillsRoot, { withFileTypes: true })
skillDirs = entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name)
} catch {
console.log(`已跳过技能链接:无法读取 ${skillsRoot}。`)
return
}
if (skillDirs.length === 0) {
console.log(`已跳过技能链接:${skillsRoot} 下未找到技能目录。`)
return
}
const codexHome = process.env.CODEX_HOME || path.join(homeDir, '.codex')
const claudeHome = process.env.CLAUDE_HOME || path.join(homeDir, '.claude')
const targets = [
{
id: 'codex',
name: 'Codex',
dir: path.join(codexHome, 'skills')
},
{
id: 'claude',
name: 'Claude',
dir: path.join(claudeHome, 'skills')
}
]
const targetsWithStatus = []
for (const target of targets) {
const status = await getTargetLinkStatus(target.dir, skillsRoot, skillDirs)
targetsWithStatus.push({ ...target, status })
}
if (targetsWithStatus.every((target) => target.status.allLinked)) {
console.log('已跳过技能链接:目标目录已全部链接。')
return
}
if (isLifecycle && !forcePrompt) {
const pending = targetsWithStatus.filter((target) => !target.status.allLinked)
const pendingNames = pending.map((target) => target.name).join(', ')
console.log(`技能链接待处理:${pendingNames}。`)
console.log('请运行 `node scripts/link-skills.js` 进行链接。')
return
}
if (!canPrompt && !forcePrompt) {
console.log('已跳过技能链接:当前为非交互终端。')
console.log('请在终端运行 `node scripts/link-skills.js` 进行链接。')
return
}
let promptContext = null
let cleanupPromptContext = () => {}
if (!canPrompt) {
const fallback = createConsolePromptContext()
if (!fallback) {
console.log('已跳过技能链接:无法打开控制台用于交互。')
return
}
promptContext = fallback.context
cleanupPromptContext = fallback.cleanup
}
try {
const useChineseHelp = isChineseLocale()
const allTargetsValue = '__all__'
const keyLabelMap = useChineseHelp
? new Map([
['space', '空格'],
['⏎', '回车']
])
: null
const actionLabelMap = useChineseHelp
? new Map([
['navigate', '移动'],
['select', '选择'],
['all', '全选'],
['invert', '反选'],
['submit', '提交']
])
: null
const selectedTargets = await checkbox(
{
message: '请选择要链接的目标:',
required: true,
theme: {
icon: {
checked: '✓',
unchecked: '○',
cursor: ''
},
style: {
keysHelpTip: (keys) =>
keys
.map(([key, action]) => {
const keyLabel = keyLabelMap?.get(key) ?? key
const actionLabel = actionLabelMap?.get(action) ?? action
return `${styleText('bold', keyLabel)} ${styleText('dim', actionLabel)}`
})
.join(styleText('dim', ' · '))
}
},
choices: [
{
name: '链接全部未链接目标',
value: allTargetsValue,
checked: false
},
...targetsWithStatus.map((target) => ({
name: `${target.name} (${target.dir})`,
value: target.id,
checked: false,
disabled: target.status.allLinked ? '已链接' : false
}))
]
},
promptContext ?? undefined
)
const shouldLinkAll = selectedTargets.includes(allTargetsValue)
const selectedIds = shouldLinkAll
? targetsWithStatus.filter((target) => !target.status.allLinked).map((target) => target.id)
: selectedTargets.filter((value) => value !== allTargetsValue)
if (selectedIds.length === 0) {
console.log('已跳过技能链接:未选择目标。')
return
}
for (const target of targetsWithStatus) {
if (!selectedIds.includes(target.id)) {
continue
}
await linkSkillsToTarget(target.dir, skillsRoot, skillDirs)
}
} finally {
cleanupPromptContext()
}
}
main().catch((error) => {
console.log('技能链接失败。')
if (error instanceof Error) {
console.log(error.message)
}
})
function isChineseLocale() {
const candidates = [
process.env.LC_ALL,
process.env.LC_MESSAGES,
process.env.LANG,
Intl.DateTimeFormat().resolvedOptions().locale
].filter(Boolean)
const locale = candidates.join(' ').toLowerCase()
return locale.includes('zh')
}
function createConsolePromptContext() {
const isWindows = process.platform === 'win32'
const candidates = isWindows
? [
{ input: '\\\\.\\CONIN$', output: '\\\\.\\CONOUT$' },
{ input: 'CONIN$', output: 'CONOUT$' }
]
: [{ input: '/dev/tty', output: '/dev/tty' }]
for (const candidate of candidates) {
try {
const inputFd = openSync(candidate.input, 'r')
const outputFd = openSync(candidate.output, 'w')
const input = new ReadStream(inputFd)
const output = new WriteStream(outputFd)
return {
context: { input, output },
cleanup: () => {
input.destroy()
output.destroy()
}
}
} catch {}
}
return null
}
async function getTargetLinkStatus(targetDir, sourceRoot, skillNames) {
let linkedCount = 0
for (const skillName of skillNames) {
const source = path.join(sourceRoot, skillName)
const dest = path.join(targetDir, skillName)
if (await isSkillLinked(source, dest)) {
linkedCount += 1
}
}
return {
allLinked: linkedCount === skillNames.length
}
}
async function isSkillLinked(source, dest) {
let destStat = null
try {
destStat = await fs.lstat(dest)
} catch (error) {
if (error.code !== 'ENOENT') {
console.log(`已跳过技能链接:无法检查 ${dest}。`)
}
return false
}
if (!destStat.isSymbolicLink()) {
return false
}
try {
const [destResolved, sourceResolved] = await Promise.all([fs.realpath(dest), fs.realpath(source)])
return destResolved === sourceResolved
} catch {
return false
}
}
async function linkSkillsToTarget(targetDir, sourceRoot, skillNames) {
await fs.mkdir(targetDir, { recursive: true })
const sourceRootResolved = await fs.realpath(sourceRoot)
for (const skillName of skillNames) {
const source = path.join(sourceRootResolved, skillName)
const dest = path.join(targetDir, skillName)
await ensureLink(source, dest)
}
}
async function ensureLink(source, dest) {
const sourceResolved = await fs.realpath(source)
let destStat = null
try {
destStat = await fs.lstat(dest)
} catch (error) {
if (error.code !== 'ENOENT') {
console.log(`已跳过技能链接:无法检查 ${dest}。`)
return
}
}
if (destStat) {
if (destStat.isSymbolicLink()) {
try {
const destResolved = await fs.realpath(dest)
if (destResolved === sourceResolved) {
console.log(`技能链接已存在:${dest}`)
return
}
} catch {
console.log(`已跳过技能链接:${dest} 处的链接无效。`)
return
}
}
console.log(`已跳过技能链接:${dest} 已存在。`)
return
}
try {
if (process.platform === 'win32') {
await fs.symlink(sourceResolved, dest, 'junction')
} else {
await fs.symlink(sourceResolved, dest)
}
console.log(`已创建技能链接:${dest} -> ${sourceResolved}`)
} catch {
console.log(`技能链接失败:${dest}`)
}
}