已合并
fix: 兼容Windows安装默认skills (#194) #1369
fix: 兼容Windows安装默认skills (#194) #1369
已合并
高煜博创建于 7月20日
1 个文件变更+94-28
@@ -1,10 +1,16 @@
1import type { Plugin } from "@opencode-ai/plugin"1import type { Plugin } from "@opencode-ai/plugin"
2import * as fs from "fs"2import * as fs from "fs"
3import * as path from "path"3import * as path from "path"
4+import * as os from "os"
4import crypto from "crypto"5import crypto from "crypto"
6+import { execFileSync } from "child_process"
5 7 
6const logFile = path.join(__dirname, "install_error.log")8const 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+ 
8function log(message: string) {14function 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 startDir43 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')
atomgit-bot
atomgit-botatomgit-bot7月21日

🟠 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(),确保所有衍生路径都是绝对的。

改动建议
49
- fs.symlinkSync(targetPath, linkPath, 'junction')
49
+ fs.symlinkSync(path.resolve(targetPath), linkPath, 'junction')
应用建议
likedislike
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+ 
40export const InstallSkillsPlugin: Plugin = async ({ $, directory }) => {119export 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")) {
atomgit-bot
atomgit-botatomgit-bot7月21日

🟡 Medium Priority

变更链路:原代码通过 $bash ...`` 调用 shell 脚本,超时由 timeout 命令产生,错误信息中包含 "timed out" 字符串。PR 将调用替换为 execFileSync('git', ['clone', ...], { timeout: CLONE_TIMEOUT_MS })(第 80 行),但在第 174 行的 catch 块中仍使用 error.message.includes("timed out") 检测超时。

在 Node.js 18+ 中,execFileSync 超时行为为:向子进程发送 SIGTERM → 子进程被杀死 → 抛出 Error 对象,其 error.killed === trueerror.signal === 'SIGTERM',但 error.message 中不包含 "timed out" 字符串(格式类似 "Command failed: git clone ... \n")。

因此第 174 行的 error.message.includes("timed out") 将始终返回 false,导致超时场景落入第 176–177 行的通用错误分支,用户无法看到第 175 行的超时专用中文提示("网络连接超时,无法访问远程仓库。请检查网络连接后重试。"),体验退化。

触发条件:git clone 操作超时(网络慢或仓库不可达,超过 20 秒)。

建议:将超时检测从单一的 error.message.includes("timed out") 扩展为同时检查 error.killed 属性。execFileSync 超时时会杀死子进程,设置 error.killed = trueerror.signal = 'SIGTERM',这比检查 message 字符串更可靠。也可以保留原字符串检测作为向后兼容。

改动建议
174
- if (error.message && error.message.includes("timed out")) {
174
+ if (error.message && (error.message.includes("timed out") || (error as any).killed)) {
应用建议
likedislike
109 detail = `网络连接超时,无法访问远程仓库。请检查网络连接后重试。\n${error.message}`175 detail = `网络连接超时,无法访问远程仓库。请检查网络连接后重试。\n${error.message}`
110 } else {176 } else {
111- detail = error.stderr ? `${error.message}\n${error.stderr}` : error.message177+ 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+}