fix(settings): support custom git path and robust skills bundled scanning

This commit is contained in:
晴天
2026-04-05 21:33:35 +08:00
parent 2829be1bd2
commit b2ab316353
5 changed files with 372 additions and 33 deletions

View File

@@ -129,6 +129,50 @@ function findCommandPath(command) {
}
}
function normalizeCommandPath(raw) {
if (typeof raw !== 'string') return null
const trimmed = raw.trim()
if (!trimmed) return null
const expanded = expandHomePath(trimmed)
if (!expanded) return null
const looksLikePath =
trimmed.includes('/') || trimmed.includes('\\') || trimmed.startsWith('.') || /^~[\\/]/.test(trimmed) || /^[A-Za-z]:[\\/]/.test(trimmed)
return looksLikePath ? path.resolve(expanded) : expanded
}
function readConfiguredGitPath() {
return normalizeCommandPath(readPanelConfig()?.gitPath || '')
}
function resolveGitExecutable() {
const gitPath = readConfiguredGitPath()
const isCustom = !!gitPath
const isPathLike = !!gitPath && (gitPath.includes('/') || gitPath.includes('\\') || /^[A-Za-z]:[\\/]/.test(gitPath))
return { gitPath: gitPath || 'git', isCustom, isPathLike }
}
function buildGitCommandEnv(extraEnv = {}, resolved = resolveGitExecutable()) {
const env = { ...process.env, ...(extraEnv || {}) }
if (resolved.isCustom && resolved.isPathLike) {
const dir = path.dirname(resolved.gitPath)
env.PATH = [dir, env.PATH || ''].filter(Boolean).join(path.delimiter)
}
if (resolved.isCustom) env.GIT = resolved.gitPath
return env
}
function runGitSync(args, options = {}) {
const resolved = resolveGitExecutable()
const env = buildGitCommandEnv(options.env, resolved)
const result = spawnSync(resolved.gitPath, args, {
encoding: 'utf8',
windowsHide: true,
...options,
env,
})
return { ...resolved, result }
}
function readConfiguredOpenclawSearchPaths() {
const entries = readPanelConfig()?.openclawSearchPaths
if (!Array.isArray(entries)) return []
@@ -810,25 +854,24 @@ function pickRegistryForPackage(pkg) {
}
function configureGitHttpsRules() {
try { execSync('git config --global --unset-all url.https://github.com/.insteadOf 2>&1', { timeout: 5000, windowsHide: true }) } catch {}
try { runGitSync(['config', '--global', '--unset-all', 'url.https://github.com/.insteadOf'], { timeout: 5000 }) } catch {}
let success = 0
for (const from of GIT_HTTPS_REWRITES) {
try {
execSync(`git config --global --add url.https://github.com/.insteadOf "${from}"`, { timeout: 5000, windowsHide: true })
success++
const { result } = runGitSync(['config', '--global', '--add', 'url.https://github.com/.insteadOf', from], { timeout: 5000 })
if (!result?.error && result?.status === 0) success++
} catch {}
}
return success
}
function buildGitInstallEnv() {
const env = {
...process.env,
const env = buildGitCommandEnv({
GIT_TERMINAL_PROMPT: '0',
GIT_SSH_COMMAND: 'ssh -o BatchMode=yes -o StrictHostKeyChecking=no -o IdentitiesOnly=yes',
GIT_ALLOW_PROTOCOL: 'https:http:file',
GIT_CONFIG_COUNT: String(GIT_HTTPS_REWRITES.length),
}
})
GIT_HTTPS_REWRITES.forEach((from, idx) => {
env[`GIT_CONFIG_KEY_${idx}`] = 'url.https://github.com/.insteadOf'
env[`GIT_CONFIG_VALUE_${idx}`] = from
@@ -836,6 +879,155 @@ function buildGitInstallEnv() {
return env
}
function parseSkillFrontmatterFile(skillMdPath) {
try {
const raw = fs.readFileSync(skillMdPath, 'utf8').replace(/\r\n/g, '\n')
if (!raw.startsWith('---\n')) return {}
const end = raw.indexOf('\n---\n', 4)
if (end < 0) return {}
const frontmatter = raw.slice(4, end)
const result = {}
for (const line of frontmatter.split('\n')) {
const match = line.match(/^([A-Za-z0-9_-]+)\s*:\s*(.+)$/)
if (!match) continue
result[match[1]] = match[2].trim().replace(/^['"]|['"]$/g, '')
}
return result
} catch {
return {}
}
}
function collectLocalSkillRoots() {
const roots = []
const seen = new Set()
const pushRoot = (dir, source, bundled = false) => {
if (!dir) return
const normalized = path.resolve(dir)
const key = isWindows ? normalized.toLowerCase() : normalized
if (seen.has(key)) return
seen.add(key)
roots.push({ dir: normalized, source, bundled })
}
pushRoot(path.join(OPENCLAW_DIR, 'skills'), 'OpenClaw 自定义', false)
pushRoot(path.join(homedir(), '.claude', 'skills'), 'Claude 自定义', false)
const cliPath = resolveOpenclawCliPath()
if (cliPath) {
const resolvedCli = canonicalCliPath(cliPath) || cliPath
const cliDir = path.dirname(resolvedCli)
const pkgRoots = [cliDir, path.dirname(cliDir)]
for (const pkgRoot of pkgRoots) {
const bundledDir = path.join(pkgRoot, 'skills')
if (fs.existsSync(bundledDir) && fs.statSync(bundledDir).isDirectory()) {
pushRoot(bundledDir, 'openclaw-bundled', true)
break
}
}
}
if (isWindows) {
const prefix = readWindowsNpmGlobalPrefix() || path.join(process.env.APPDATA || '', 'npm')
for (const pkg of ['openclaw', path.join('@qingchencloud', 'openclaw-zh')]) {
const bundledDir = path.join(prefix, 'node_modules', pkg, 'skills')
if (fs.existsSync(bundledDir) && fs.statSync(bundledDir).isDirectory()) {
pushRoot(bundledDir, 'openclaw-bundled', true)
}
}
}
return roots
}
function scanSingleSkill(root, name) {
const skillPath = path.join(root.dir, name)
const skillMd = path.join(skillPath, 'SKILL.md')
const packageJson = path.join(skillPath, 'package.json')
if (!fs.existsSync(skillMd) && !fs.existsSync(packageJson)) return null
const result = {
name,
source: root.source,
bundled: !!root.bundled,
filePath: skillPath,
description: '',
eligible: true,
disabled: false,
blockedByAllowlist: false,
requirements: { bins: [], anyBins: [], env: [], config: [], os: [] },
missing: { bins: [], anyBins: [], env: [], config: [], os: [] },
install: [],
}
try {
if (fs.existsSync(packageJson)) {
const pkg = JSON.parse(fs.readFileSync(packageJson, 'utf8'))
if (pkg.description) result.description = pkg.description
if (pkg.homepage) result.homepage = pkg.homepage
if (pkg.version) result.version = pkg.version
if (pkg.author) result.author = typeof pkg.author === 'string' ? pkg.author : (pkg.author?.name || '')
}
} catch {}
const frontmatter = parseSkillFrontmatterFile(skillMd)
if (frontmatter.description) result.description = frontmatter.description
if (frontmatter.fullPath) result.fullPath = frontmatter.fullPath
if (frontmatter.emoji) result.emoji = frontmatter.emoji
return result
}
function scanLocalSkillsFallback(cliError = null) {
const roots = collectLocalSkillRoots()
const skills = []
const seen = new Set()
const scannedRoots = []
for (const root of roots) {
if (!fs.existsSync(root.dir) || !fs.statSync(root.dir).isDirectory()) continue
scannedRoots.push(root.dir)
for (const entry of fs.readdirSync(root.dir, { withFileTypes: true })) {
if (!entry.isDirectory()) continue
const key = isWindows ? entry.name.toLowerCase() : entry.name
if (seen.has(key)) continue
const skill = scanSingleSkill(root, entry.name)
if (!skill) continue
seen.add(key)
skills.push(skill)
}
}
skills.sort((a, b) => String(a.name || '').localeCompare(String(b.name || '')))
const eligible = skills.filter(s => s.eligible && !s.disabled)
const missingRequirements = skills.filter(s => !s.eligible && !s.disabled && !s.blockedByAllowlist)
const disabled = skills.filter(s => s.disabled)
const blocked = skills.filter(s => s.blockedByAllowlist && !s.disabled)
return {
skills,
source: 'local-scan',
cliAvailable: false,
summary: {
total: skills.length,
eligible: eligible.length,
disabled: disabled.length,
blocked: blocked.length,
missingRequirements: missingRequirements.length,
},
eligible,
disabled,
blocked,
missingRequirements,
diagnostic: {
status: 'scanned',
scannedAt: new Date().toISOString(),
scannedRoots,
cli: cliError ? { status: 'exec-failed', message: String(cliError?.message || cliError) } : null,
},
}
}
function detectInstalledSource() {
const activeCliPath = resolveOpenclawCliPath()
const activeCliSource = classifyCliSource(activeCliPath)
@@ -4196,12 +4388,15 @@ const handlers = {
},
check_git() {
const { gitPath, isCustom, result } = runGitSync(['--version'], { timeout: 5000 })
const detectedPath = isCustom ? gitPath : findCommandPath('git')
try {
const ver = execSync('git --version', { encoding: 'utf8', timeout: 5000, windowsHide: true }).trim()
if (result?.error || result?.status !== 0) throw new Error(result?.error?.message || result?.stderr || result?.stdout || 'git not found')
const ver = String(result.stdout || result.stderr || '').trim()
const match = ver.match(/(\d+\.\d+[\.\d]*)/)
return { installed: true, version: match ? match[1] : ver, path: findCommandPath('git') }
return { installed: true, version: match ? match[1] : ver, path: detectedPath, isCustom }
} catch {
return { installed: false, path: null }
return { installed: false, version: null, path: detectedPath, isCustom }
}
},
@@ -5107,39 +5302,36 @@ const handlers = {
// Skills 管理(模拟 openclaw skills CLI JSON 输出)
skills_list() {
// 尝试真实 CLI
try {
const out = execSync('npx -y openclaw skills list --json', { encoding: 'utf8', timeout: 30000 })
const out = execOpenclawSync(['skills', 'list', '--json'], { encoding: 'utf8', timeout: 30000, cwd: homedir(), windowsHide: true }, '读取 Skills 列表失败')
return extractCliJson(out)
} catch {
// CLI 不可用时返回 mock 数据
return {
skills: [
{ name: 'github', description: 'GitHub operations via gh CLI: issues, PRs, CI runs, code review.', source: 'openclaw-bundled', bundled: true, emoji: '🐙', eligible: true, disabled: false, blockedByAllowlist: false, requirements: { bins: ['gh'], anyBins: [], env: [], config: [], os: [] }, missing: { bins: [], anyBins: [], env: [], config: [], os: [] }, install: [{ id: 'brew', kind: 'brew', label: 'Install GitHub CLI (brew)', bins: ['gh'] }] },
{ name: 'weather', description: 'Get current weather and forecasts via wttr.in. No API key needed.', source: 'openclaw-bundled', bundled: true, emoji: '🌤️', eligible: true, disabled: false, blockedByAllowlist: false, requirements: { bins: ['curl'], anyBins: [], env: [], config: [], os: [] }, missing: { bins: [], anyBins: [], env: [], config: [], os: [] }, install: [] },
{ name: 'summarize', description: 'Summarize web pages, PDFs, images, audio and more.', source: 'openclaw-bundled', bundled: true, emoji: '📝', eligible: false, disabled: false, blockedByAllowlist: false, requirements: { bins: [], anyBins: [], env: [], config: [], os: [] }, missing: { bins: [], anyBins: [], env: [], config: [], os: [] }, install: [] },
{ name: 'slack', description: 'Send and read Slack messages via CLI.', source: 'openclaw-bundled', bundled: true, emoji: '💬', eligible: false, disabled: false, blockedByAllowlist: false, requirements: { bins: ['slack-cli'], anyBins: [], env: [], config: [], os: [] }, missing: { bins: ['slack-cli'], anyBins: [], env: [], config: [], os: [] }, install: [{ id: 'brew', kind: 'brew', label: 'Install Slack CLI (brew)', bins: ['slack-cli'] }] },
{ name: 'notion', description: 'Create and search Notion pages using the API.', source: 'openclaw-bundled', bundled: true, emoji: '📓', eligible: false, disabled: true, blockedByAllowlist: false, requirements: { bins: [], anyBins: [], env: ['NOTION_API_KEY'], config: [], os: [] }, missing: { bins: [], anyBins: [], env: ['NOTION_API_KEY'], config: [], os: [] }, install: [] },
],
source: 'mock',
cliAvailable: false,
}
} catch (e) {
return scanLocalSkillsFallback(e)
}
},
skills_info({ name }) {
try {
const out = execSync(`npx -y openclaw skills info ${JSON.stringify(name)} --json`, { encoding: 'utf8', timeout: 30000 })
const out = execOpenclawSync(['skills', 'info', String(name || '').trim(), '--json'], { encoding: 'utf8', timeout: 30000, cwd: homedir(), windowsHide: true }, '查看 Skill 详情失败')
return extractCliJson(out)
} catch (e) {
const fallback = scanLocalSkillsFallback(e).skills.find(skill => skill.name === String(name || '').trim())
if (fallback) return fallback
throw new Error('查看详情失败: ' + (e.message || e))
}
},
skills_check() {
try {
const out = execSync('npx -y openclaw skills check --json', { encoding: 'utf8', timeout: 30000 })
const out = execOpenclawSync(['skills', 'check', '--json'], { encoding: 'utf8', timeout: 30000, cwd: homedir(), windowsHide: true }, '检查 Skills 依赖失败')
return extractCliJson(out)
} catch {
return { summary: { total: 0, eligible: 0, disabled: 0, blocked: 0, missingRequirements: 0 }, eligible: [], disabled: [], blocked: [], missingRequirements: [] }
} catch (e) {
const fallback = scanLocalSkillsFallback(e)
return {
summary: fallback.summary,
eligible: fallback.eligible,
disabled: fallback.disabled,
blocked: fallback.blocked,
missingRequirements: fallback.missingRequirements,
}
}
},
skills_install_dep({ kind, spec }) {

View File

@@ -275,13 +275,27 @@ fn recommended_version_for(source: &str) -> Option<String> {
}
}
/// 获取用户配置的 git 可执行文件路径,回退到 "git"
fn configured_git_path() -> Option<String> {
super::read_panel_config_value()
.and_then(|v| v.get("gitPath")?.as_str().map(String::from))
.map(|custom| custom.trim().to_string())
.filter(|custom| !custom.is_empty())
}
/// 获取用户配置的 git 可执行文件路径,回退到 "git"
pub fn git_executable() -> String {
configured_git_path().unwrap_or_else(|| "git".into())
}
fn configure_git_https_rules() -> usize {
let git = git_executable();
// Collect unique target prefixes to unset old rules
let targets: std::collections::HashSet<&str> =
GIT_HTTPS_REWRITES.iter().map(|(t, _)| *t).collect();
for target in &targets {
let key = format!("url.{target}.insteadOf");
let mut unset = Command::new("git");
let mut unset = Command::new(&git);
unset.args(["config", "--global", "--unset-all", &key]);
#[cfg(target_os = "windows")]
unset.creation_flags(0x08000000);
@@ -291,7 +305,7 @@ fn configure_git_https_rules() -> usize {
let mut success = 0;
for (target, from) in GIT_HTTPS_REWRITES {
let key = format!("url.{target}.insteadOf");
let mut cmd = Command::new("git");
let mut cmd = Command::new(&git);
cmd.args(["config", "--global", "--add", &key, from]);
#[cfg(target_os = "windows")]
cmd.creation_flags(0x08000000);
@@ -303,6 +317,21 @@ fn configure_git_https_rules() -> usize {
}
fn apply_git_install_env(cmd: &mut Command) {
if let Some(custom_git) = configured_git_path() {
let git_path = PathBuf::from(&custom_git);
if let Some(parent) = git_path.parent() {
let mut paths: Vec<PathBuf> = std::env::var_os("PATH")
.map(|value| std::env::split_paths(&value).collect())
.unwrap_or_default();
if !paths.iter().any(|p| p == parent) {
paths.insert(0, parent.to_path_buf());
}
if let Ok(joined) = std::env::join_paths(paths) {
cmd.env("PATH", joined);
}
}
cmd.env("GIT", &custom_git);
}
crate::commands::apply_proxy_env(cmd);
cmd.env("GIT_TERMINAL_PROMPT", "0")
.env(
@@ -5297,8 +5326,15 @@ pub fn set_npm_registry(registry: String) -> Result<(), String> {
#[tauri::command]
pub fn check_git() -> Result<Value, String> {
let mut result = serde_json::Map::new();
let git_path = find_git_path();
let mut cmd = Command::new("git");
let configured = configured_git_path();
let git = configured.clone().unwrap_or_else(|| "git".into());
let is_custom = configured.is_some();
let git_path = if is_custom {
Some(git.clone())
} else {
find_git_path()
};
let mut cmd = Command::new(&git);
cmd.arg("--version");
#[cfg(target_os = "windows")]
cmd.creation_flags(0x08000000);
@@ -5311,11 +5347,13 @@ pub fn check_git() -> Result<Value, String> {
"path".into(),
git_path.map(Value::String).unwrap_or(Value::Null),
);
result.insert("isCustom".into(), Value::Bool(is_custom));
}
_ => {
result.insert("installed".into(), Value::Bool(false));
result.insert("version".into(), Value::Null);
result.insert("path".into(), Value::Null);
result.insert("isCustom".into(), Value::Bool(is_custom));
}
}
Ok(Value::Object(result))

View File

@@ -821,6 +821,36 @@ fn custom_skill_roots() -> Vec<(std::path::PathBuf, &'static str)> {
roots.push((claude_skills, "Claude 自定义"));
}
}
// 从已解析的 CLI 路径推导 npm 包内的 bundled skills 目录
// 例如 CLI 在 /usr/lib/node_modules/openclaw/bin/openclaw
// → 包根 /usr/lib/node_modules/openclaw/
// → skills 目录 /usr/lib/node_modules/openclaw/skills/
if let Some(cli_path) = crate::utils::resolve_openclaw_cli_path() {
let cli = std::path::PathBuf::from(&cli_path);
let cli = std::fs::canonicalize(&cli).unwrap_or(cli);
// CLI 可能在 bin/ 子目录或包根目录
for ancestor in [cli.parent(), cli.parent().and_then(|p| p.parent())] {
if let Some(pkg_root) = ancestor {
let bundled = pkg_root.join("skills");
if bundled.is_dir() && !roots.iter().any(|(dir, _)| dir == &bundled) {
roots.push((bundled, "OpenClaw 内置"));
break;
}
}
}
}
#[cfg(target_os = "windows")]
if let Some(prefix) = super::windows_npm_global_prefix() {
for pkg in ["openclaw", "@qingchencloud/openclaw-zh"] {
let bundled = std::path::PathBuf::from(&prefix)
.join("node_modules")
.join(pkg)
.join("skills");
if bundled.is_dir() && !roots.iter().any(|(dir, _)| dir == &bundled) {
roots.push((bundled, "OpenClaw 内置"));
}
}
}
roots
}

View File

@@ -76,4 +76,10 @@ export default {
proxyCleared: _('网络代理已关闭', 'Network proxy disabled', '網路代理已關閉', 'ネットワークプロキシ無効化', '네트워크 프록시 비활성화됨'),
modelProxyOn: _('模型请求将走代理', 'Model requests will use proxy', '模型請求將走代理', 'モデルリクエストはプロキシを使用します', '모델 요청이 프록시를 사용합니다'),
modelProxyOff: _('模型请求已关闭代理', 'Model requests proxy disabled', '模型請求已關閉代理', 'モデルリクエストプロキシ無効化', '모델 요청 프록시 비활성화됨'),
gitPath: _('Git 可执行文件路径', 'Git Executable Path', 'Git 可執行檔路徑', 'Git 実行ファイルパス', 'Git 실행 파일 경로'),
gitPathHint: _('自定义 Git 可执行文件路径。留空则自动从系统 PATH 中查找。当系统找不到 Git 时,可在此手动指定完整路径。', 'Custom Git executable path. Leave empty to auto-detect from system PATH. Specify the full path here if the system cannot find Git.', '自定義 Git 可執行檔路徑。留空則自動從系統 PATH 中尋找。當系統找不到 Git 時,可在此手動指定完整路徑。', 'カスタム Git 実行ファイルパス。空欄にするとシステム PATH から自動検出します。システムが Git を見つけられない場合はここにフルパスを指定してください。'),
gitPathPlaceholder: _('留空自动检测,例如 C:\\Program Files\\Git\\cmd\\git.exe', 'Leave empty for auto-detect, e.g. C:\\Program Files\\Git\\cmd\\git.exe', '留空自動檢測,例如 /usr/local/bin/git', '空欄で自動検出、例: C:\\Program Files\\Git\\cmd\\git.exe'),
gitPathSaved: _('Git 路径已保存', 'Git path saved', 'Git 路徑已儲存', 'Git パス保存済み', 'Git 경로 저장됨'),
gitPathCleared: _('已恢复 Git 自动检测', 'Git auto-detect restored', '已恢復 Git 自動檢測', 'Git 自動検出に戻しました', 'Git 자동 감지로 복원됨'),
gitPathInvalid: _('指定的 Git 路径不存在', 'The specified Git path does not exist', '指定的 Git 路徑不存在', '指定された Git パスが存在しません'),
}

View File

@@ -100,6 +100,11 @@ export async function render() {
<div id="docker-defaults-bar"><div class="stat-card loading-placeholder" style="height:84px"></div></div>
</div>
<div class="config-section" id="git-path-section">
<div class="config-section-title">${t('settings.gitPath')}</div>
<div id="git-path-bar"><div class="stat-card loading-placeholder" style="height:48px"></div></div>
</div>
<div class="config-section" id="cli-binding-section">
<div class="config-section-title">${t('settings.openclawCli')}</div>
<div id="cli-binding-bar"><div class="stat-card loading-placeholder" style="height:48px"></div></div>
@@ -123,7 +128,7 @@ export async function render() {
}
async function loadAll(page) {
const tasks = [loadProxyConfig(page), loadModelProxyConfig(page), loadOpenclawDir(page), loadOpenclawSearchPaths(page), loadDockerDefaults(page), loadCliBinding(page)]
const tasks = [loadProxyConfig(page), loadModelProxyConfig(page), loadOpenclawDir(page), loadOpenclawSearchPaths(page), loadDockerDefaults(page), loadGitPath(page), loadCliBinding(page)]
tasks.push(loadRegistry(page))
if (window.__TAURI_INTERNALS__) tasks.push(loadAutostart(page))
await Promise.all(tasks)
@@ -460,6 +465,12 @@ function bindEvents(page) {
case 'save-docker-defaults':
await handleSaveDockerDefaults(page)
break
case 'save-git-path':
await handleSaveGitPath(page)
break
case 'reset-git-path':
await handleResetGitPath(page)
break
case 'bind-cli':
await handleBindCli(page, btn.dataset.path)
break
@@ -548,6 +559,68 @@ async function handleSaveRegistry(page) {
toast(t('settings.registrySaved'), 'success')
}
// ===== Git 路径 =====
async function loadGitPath(page) {
const bar = page.querySelector('#git-path-bar')
if (!bar) return
try {
const gitInfo = await api.checkGit()
const cfg = await api.readPanelConfig()
const customValue = cfg?.gitPath || ''
const invalidCustom = gitInfo.isCustom && !gitInfo.installed
const statusText = gitInfo.installed
? `<span style="color:var(--success)">✓ ${escapeHtml(gitInfo.version || 'Git')}</span>`
: invalidCustom
? `<span style="color:var(--error)">✗ ${t('settings.gitPathInvalid')}</span>`
: `<span style="color:var(--error)">✗ Git ${t('setup.notInstalled')}</span>`
const pathText = gitInfo.path ? `<span style="font-size:var(--font-size-xs);opacity:0.7">${escapeHtml(gitInfo.path)}</span>` : ''
const customBadge = gitInfo.isCustom ? `<span class="badge" style="margin-left:6px;font-size:10px">${t('settings.customBadge')}</span>` : ''
bar.innerHTML = `
<div class="stat-card" style="padding:16px">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px">
${statusText}${customBadge}
</div>
${pathText ? `<div style="margin-bottom:10px">${pathText}</div>` : ''}
<p style="font-size:var(--font-size-xs);color:var(--text-tertiary);margin-bottom:12px;line-height:1.5">${t('settings.gitPathHint')}</p>
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap">
<input class="input" data-name="git-path" value="${escapeHtml(customValue)}" placeholder="${t('settings.gitPathPlaceholder')}" style="flex:1;min-width:200px">
<button class="btn btn-primary btn-sm" data-action="save-git-path">${t('common.save')}</button>
<button class="btn btn-secondary btn-sm" data-action="reset-git-path">${t('settings.resetDefault')}</button>
</div>
</div>`
} catch (e) {
bar.innerHTML = `<div class="stat-card" style="padding:16px;color:var(--error)">${e}</div>`
}
}
async function handleSaveGitPath(page) {
const input = page.querySelector('[data-name="git-path"]')
const value = (input?.value || '').trim()
const cfg = await api.readPanelConfig()
if (value) {
cfg.gitPath = value
} else {
delete cfg.gitPath
}
await api.writePanelConfig(cfg)
const gitInfo = await api.checkGit()
if (value && gitInfo.isCustom && !gitInfo.installed) {
toast(t('settings.gitPathInvalid'), 'error')
} else {
toast(value ? t('settings.gitPathSaved') : t('settings.gitPathCleared'), 'success')
}
await loadGitPath(page)
}
async function handleResetGitPath(page) {
const cfg = await api.readPanelConfig()
delete cfg.gitPath
await api.writePanelConfig(cfg)
toast(t('settings.gitPathCleared'), 'success')
await loadGitPath(page)
}
// ===== CLI 绑定 =====
async function loadCliBinding(page) {