| @@ -1,10 +1,16 @@ | |||||||
| 1 | import type { Plugin } from "@opencode-ai/plugin" | 1 | import type { Plugin } from "@opencode-ai/plugin" | ||||
| 2 | import * as fs from "fs" | 2 | import * as fs from "fs" | ||||
| 3 | import * as path from "path" | 3 | import * as path from "path" | ||||
| 4 | +import * as os from "os" | ||||||
| 4 | import crypto from "crypto" | 5 | import crypto from "crypto" | ||||
| 6 | +import { execFileSync } from "child_process" | ||||||
| 5 | 7 | ||||||
| 6 | const logFile = path.join(__dirname, "install_error.log") | 8 | const logFile = path.join(__dirname, "install_error.log") | ||||
| 7 | 9 | ||||||
| 10 | +const REPO_URL = "https://gitcode.com/cann-agent/skills.git" | ||||||
| 11 | +const DEFAULT_SKILLS = ["gitcode-pr", "gitcode-issue", "api-doc-generator", "gitcode-pipeline"] | ||||||
| 12 | +const CLONE_TIMEOUT_MS = 20000 | ||||||
| 13 | + | ||||||
| 8 | function log(message: string) { | 14 | function log(message: string) { | ||||
| 9 | const timestamp = new Date().toISOString() | 15 | const timestamp = new Date().toISOString() | ||||
| 10 | fs.appendFileSync(logFile, `[${timestamp}] ${message}\n`) | 16 | fs.appendFileSync(logFile, `[${timestamp}] ${message}\n`) | ||||
| @@ -37,40 +43,98 @@ function findGitRoot(startDir: string): string { | |||||||
| 37 | return startDir | 43 | return startDir | ||||
| 38 | } | 44 | } | ||||
| 39 | 45 | ||||||
| 46 | +function createSkillLink(targetPath: string, linkPath: string): void { | ||||||
| 47 | + fs.rmSync(linkPath, { recursive: true, force: true }) | ||||||
| 48 | + if (process.platform === 'win32') { | ||||||
| 49 | + fs.symlinkSync(targetPath, linkPath, 'junction') | ||||||
| 50 | + } else { | ||||||
| 51 | + const relTarget = path.relative(path.dirname(linkPath), targetPath) | ||||||
| 52 | + fs.symlinkSync(relTarget, linkPath) | ||||||
| 53 | + } | ||||||
| 54 | +} | ||||||
| 55 | + | ||||||
| 56 | +function ensureGitignore(gitignorePath: string): void { | ||||||
| 57 | + let content = "" | ||||||
| 58 | + if (fs.existsSync(gitignorePath)) { | ||||||
| 59 | + content = fs.readFileSync(gitignorePath, 'utf-8') | ||||||
| 60 | + } | ||||||
| 61 | + const lines = content.split('\n') | ||||||
| 62 | + let changed = false | ||||||
| 63 | + for (const skill of DEFAULT_SKILLS) { | ||||||
| 64 | + const entry = `.claude/skills/${skill}` | ||||||
| 65 | + if (!lines.includes(entry)) { | ||||||
| 66 | + if (content.length > 0 && !content.endsWith('\n')) { | ||||||
| 67 | + content += '\n' | ||||||
| 68 | + } | ||||||
| 69 | + content += entry + '\n' | ||||||
| 70 | + lines.push(entry) | ||||||
| 71 | + changed = true | ||||||
| 72 | + } | ||||||
| 73 | + } | ||||||
| 74 | + if (changed) { | ||||||
| 75 | + fs.writeFileSync(gitignorePath, content) | ||||||
| 76 | + } | ||||||
| 77 | +} | ||||||
| 78 | + | ||||||
| 79 | +function cloneSkillsRepo(tmpRepo: string): void { | ||||||
| 80 | + execFileSync('git', ['clone', '--depth', '1', REPO_URL, tmpRepo], { | ||||||
| 81 | + timeout: CLONE_TIMEOUT_MS, | ||||||
| 82 | + stdio: 'pipe' | ||||||
| 83 | + }) | ||||||
| 84 | +} | ||||||
| 85 | + | ||||||
| 86 | +function installSkillsToRemote(rootDir: string): void { | ||||||
| 87 | + const skillsDir = path.join(rootDir, ".claude", "skills") | ||||||
| 88 | + const remoteDir = path.join(skillsDir, "_remote") | ||||||
| 89 | + fs.mkdirSync(remoteDir, { recursive: true }) | ||||||
| 90 | + | ||||||
| 91 | + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "skills-install-")) | ||||||
| 92 | + try { | ||||||
| 93 | + const tmpRepo = path.join(tmpDir, "skills") | ||||||
| 94 | + cloneSkillsRepo(tmpRepo) | ||||||
| 95 | + | ||||||
| 96 | + const repoSkillsDir = path.join(tmpRepo, "skills") | ||||||
| 97 | + if (!fs.existsSync(repoSkillsDir)) { | ||||||
| 98 | + throw new Error(`skills directory not found in repository: ${repoSkillsDir}`) | ||||||
| 99 | + } | ||||||
| 100 | + | ||||||
| 101 | + for (const skill of DEFAULT_SKILLS) { | ||||||
| 102 | + const src = path.join(repoSkillsDir, skill) | ||||||
| 103 | + if (!fs.existsSync(src)) { | ||||||
| 104 | + log(`Skill '${skill}' not found in repository`) | ||||||
| 105 | + continue | ||||||
| 106 | + } | ||||||
| 107 | + const dest = path.join(remoteDir, skill) | ||||||
| 108 | + fs.rmSync(dest, { recursive: true, force: true }) | ||||||
| 109 | + fs.cpSync(src, dest, { recursive: true }) | ||||||
| 110 | + createSkillLink(dest, path.join(skillsDir, skill)) | ||||||
| 111 | + } | ||||||
| 112 | + | ||||||
| 113 | + ensureGitignore(path.join(rootDir, ".gitignore")) | ||||||
| 114 | + } finally { | ||||||
| 115 | + fs.rmSync(tmpDir, { recursive: true, force: true }) | ||||||
| 116 | + } | ||||||
| 117 | +} | ||||||
| 118 | + | ||||||
| 40 | export const InstallSkillsPlugin: Plugin = async ({ $, directory }) => { | 119 | export const InstallSkillsPlugin: Plugin = async ({ $, directory }) => { | ||||
| 41 | const rootDir = findGitRoot(directory) | 120 | const rootDir = findGitRoot(directory) | ||||
| 42 | const installSkills = async () => { | 121 | const installSkills = async () => { | ||||
| 43 | try { | 122 | try { | ||||
| 44 | - // 记录安装前两个skill文件的状态 | 123 | + // 记录安装前四个skill文件的状态 | ||||
| 45 | - const skillsToCheck = [ | 124 | + const skillsToCheck = DEFAULT_SKILLS.map(name => ({ | ||||
| 46 | - { name: 'gitcode-pr', path: path.join(rootDir, ".claude", "skills", "gitcode-pr", "SKILL.md") }, | 125 | + name, | ||||
| 47 | - { name: 'gitcode-issue', path: path.join(rootDir, ".claude", "skills", "gitcode-issue", "SKILL.md") }, | 126 | + path: path.join(rootDir, ".claude", "skills", name, "SKILL.md") | ||||
| 48 | - { name: 'api-doc-generator', path: path.join(rootDir, ".claude", "skills", "api-doc-generator", "SKILL.md") }, | 127 | + })) | ||||
| 49 | - { name: 'gitcode-pipeline', path: path.join(rootDir, ".claude", "skills", "gitcode-pipeline", "SKILL.md") } | ||||||
| 50 | - ] | ||||||
| 51 | 128 | ||||||
| 52 | const beforeStates: SkillState[] = skillsToCheck.map(skill => ({ | 129 | const beforeStates: SkillState[] = skillsToCheck.map(skill => ({ | ||||
| 53 | name: skill.name, | 130 | name: skill.name, | ||||
| 54 | exists: fs.existsSync(skill.path), | 131 | exists: fs.existsSync(skill.path), | ||||
| 55 | hash: getFileHash(skill.path) | 132 | hash: getFileHash(skill.path) | ||||
| 56 | })) | 133 | })) | ||||
| 57 | - // 检测是否有 bash 环境(Windows 通常没有 bash) | ||||||
| 58 | - const hasBash = (() => { | ||||||
| 59 | - try { | ||||||
| 60 | - require('child_process').execSync('bash --version', { stdio: 'ignore' }) | ||||||
| 61 | - return true | ||||||
| 62 | - } catch { | ||||||
| 63 | - return false | ||||||
| 64 | - } | ||||||
| 65 | - })() | ||||||
| 66 | - if (!hasBash) { | ||||||
| 67 | - process.stdout.write(`💡 提示:当前环境缺少 bash,请输入指令"安装默认skill"手动安装\n\n`) | ||||||
| 68 | - return | ||||||
| 69 | - } | ||||||
| 70 | - const scriptPath = path.join(rootDir, ".claude", "skills", "default-skills", "scripts", "install-default-skills.sh") | ||||||
| 71 | - await $`bash ${scriptPath} > /dev/null` | ||||||
| 72 | 134 | ||||||
| 73 | - // 记录安装后两个skill文件的状态 | 135 | + installSkillsToRemote(rootDir) | ||||
| 136 | + | ||||||
| 137 | + // 记录安装后四个skill文件的状态 | ||||||
| 74 | const afterStates: SkillState[] = skillsToCheck.map(skill => ({ | 138 | const afterStates: SkillState[] = skillsToCheck.map(skill => ({ | ||||
| 75 | name: skill.name, | 139 | name: skill.name, | ||||
| 76 | exists: fs.existsSync(skill.path), | 140 | exists: fs.existsSync(skill.path), | ||||
| @@ -94,21 +158,23 @@ export const InstallSkillsPlugin: Plugin = async ({ $, directory }) => { | |||||||
| 94 | } | 158 | } | ||||
| 95 | } | 159 | } | ||||
| 96 | 160 | ||||||
| 97 | - // 只有当两个skill都在安装前后完全相同时才不打印提示 | 161 | + // 只有当所有skill都在安装前后完全相同时才不打印提示 | ||||
| 98 | if (hasChanges && changedSkills.length > 0) { | 162 | if (hasChanges && changedSkills.length > 0) { | ||||
| 99 | setTimeout(() => { | 163 | setTimeout(() => { | ||||
| 100 | process.stdout.write(`💡 ${changedSkills.join(', ')},重启opencode才能完全生效\n\n`) | 164 | process.stdout.write(`💡 ${changedSkills.join(', ')},重启opencode才能完全生效\n\n`) | ||||
| 101 | }, 1000) | 165 | }, 1000) | ||||
| 102 | } | 166 | } | ||||
| 103 | -} catch (error) { | 167 | +} catch (err) { | ||||
| 168 | + const error = err as Error & { stderr?: Buffer } | ||||||
| 104 | log(`Command failed: ${error.message}`) | 169 | log(`Command failed: ${error.message}`) | ||||
| 105 | - if (error.stderr) log(`stderr from error: ${error.stderr}`) | 170 | + const stderrStr = error.stderr ? error.stderr.toString() : "" | ||||
| 171 | + if (stderrStr) log(`stderr from error: ${stderrStr}`) | ||||||
| 106 | const errorMarkerPath = path.join(rootDir, ".opencode_skills_error") | 172 | const errorMarkerPath = path.join(rootDir, ".opencode_skills_error") | ||||
| 107 | let detail = "" | 173 | let detail = "" | ||||
| 108 | if (error.message && error.message.includes("timed out")) { | 174 | if (error.message && error.message.includes("timed out")) { | ||||
🟡 Medium Priority 变更链路:原代码通过 在 Node.js 18+ 中, 因此第 174 行的 触发条件:git clone 操作超时(网络慢或仓库不可达,超过 20 秒)。 建议:将超时检测从单一的 改动建议
![]() ![]() | |||||||
| 109 | detail = `网络连接超时,无法访问远程仓库。请检查网络连接后重试。\n${error.message}` | 175 | detail = `网络连接超时,无法访问远程仓库。请检查网络连接后重试。\n${error.message}` | ||||
| 110 | } else { | 176 | } else { | ||||
| 111 | - detail = error.stderr ? `${error.message}\n${error.stderr}` : error.message | 177 | + detail = stderrStr ? `${error.message}\n${stderrStr}` : error.message | ||||
| 112 | } | 178 | } | ||||
| 113 | const errorMessage = `❌ 安装默认技能时出错了,请输入指令"安装默认skill"重新安装\n错误详情: ${detail}\n` | 179 | const errorMessage = `❌ 安装默认技能时出错了,请输入指令"安装默认skill"重新安装\n错误详情: ${detail}\n` | ||||
| 114 | fs.writeFileSync(errorMarkerPath, errorMessage) | 180 | fs.writeFileSync(errorMarkerPath, errorMessage) | ||||
| @@ -126,4 +192,4 @@ export const InstallSkillsPlugin: Plugin = async ({ $, directory }) => { | |||||||
| 126 | return { | 192 | return { | ||||
| 127 | event: async ({ event }) => {} | 193 | event: async ({ event }) => {} | ||||
| 128 | } | 194 | } | ||||
| 129 | -} | 195 | +} | ||||
已合并
fix: 兼容Windows安装默认skills (#194) #1369
高煜博创建于 7月20日
fix: 兼容Windows安装默认skills (#194) #1369
已合并
共 1 个文件变更+94-28


🟠 High Priority
变更链路:
InstallSkillsPlugin接收的directory参数未经过path.resolve()处理 →findGitRoot(directory)在第 35–44 行直接返回startDir(可能是相对路径如".") →installSkillsToRemote(rootDir)在第 87–88 行通过path.join拼接出skillsDir/remoteDir→ 第 107 行的dest(即_remote/<skill>真实目录路径)同样继承了相对性 → 第 110 行调用createSkillLink(dest, ...)。在
createSkillLink的第 49 行,Windows 分支执行fs.symlinkSync(targetPath, linkPath, 'junction')。Node.js 文档明确要求:Windows junction 的 target 必须是绝对路径("Junctions require the destination path to be absolute")。当targetPath为相对路径(如.claude/skills/_remote/gitcode-pr)时,fs.symlinkSync会抛出异常,导致整个安装流程失败。这正是本 PR 要解决的 Windows 兼容性问题的核心场景——PR 设计文档也明确写了"Windows junction 不需要管理员权限,但仅对目录有效且 target 需绝对路径"。当前实现违背了这一设计约束。
触发条件:opencode 传入的
directory为非绝对路径(例如工作目录为"."、相对路径"../project"等),这在实践中很常见。建议:在
createSkillLink的 Windows 分支中,对targetPath使用path.resolve()转为绝对路径后再传给fs.symlinkSync。同时也可以考虑在installSkillsToRemote入口处对rootDir做一次path.resolve(),确保所有衍生路径都是绝对的。