diff --git a/scripts/dev-api.js b/scripts/dev-api.js index 669d0e7..6532b45 100644 --- a/scripts/dev-api.js +++ b/scripts/dev-api.js @@ -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 }) { diff --git a/src-tauri/src/commands/config.rs b/src-tauri/src/commands/config.rs index 2658787..08164aa 100644 --- a/src-tauri/src/commands/config.rs +++ b/src-tauri/src/commands/config.rs @@ -275,13 +275,27 @@ fn recommended_version_for(source: &str) -> Option { } } +/// 获取用户配置的 git 可执行文件路径,回退到 "git" +fn configured_git_path() -> Option { + 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 = 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 { 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 { "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)) diff --git a/src-tauri/src/commands/skills.rs b/src-tauri/src/commands/skills.rs index af3ae50..90bbe9c 100644 --- a/src-tauri/src/commands/skills.rs +++ b/src-tauri/src/commands/skills.rs @@ -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 } diff --git a/src/locales/modules/settings.js b/src/locales/modules/settings.js index d605d18..09839ff 100644 --- a/src/locales/modules/settings.js +++ b/src/locales/modules/settings.js @@ -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 パスが存在しません'), } diff --git a/src/pages/settings.js b/src/pages/settings.js index 1152f78..682f618 100644 --- a/src/pages/settings.js +++ b/src/pages/settings.js @@ -100,6 +100,11 @@ export async function render() {
+
+
${t('settings.gitPath')}
+
+
+
${t('settings.openclawCli')}
@@ -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 + ? `✓ ${escapeHtml(gitInfo.version || 'Git')}` + : invalidCustom + ? `✗ ${t('settings.gitPathInvalid')}` + : `✗ Git ${t('setup.notInstalled')}` + const pathText = gitInfo.path ? `${escapeHtml(gitInfo.path)}` : '' + const customBadge = gitInfo.isCustom ? `${t('settings.customBadge')}` : '' + bar.innerHTML = ` +
+
+ ${statusText}${customBadge} +
+ ${pathText ? `
${pathText}
` : ''} +

${t('settings.gitPathHint')}

+
+ + + +
+
` + } catch (e) { + bar.innerHTML = `
${e}
` + } +} + +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) {