feat(skills): auto-backup skill files before uninstall

Create a local backup under ~/.cc-switch/skill-backups/ before removing
skill directories. The backup includes all skill files and a meta.json
with original skill metadata. Old backups are pruned to keep at most 20.
The backup path is returned to the frontend and shown in the success
toast. Bump version to 3.12.3.
This commit is contained in:
Jason
2026-03-15 23:26:50 +08:00
parent 04254d6ffe
commit 9336001746
20 changed files with 850 additions and 28 deletions
+29
View File
@@ -5,6 +5,35 @@ All notable changes to CC Switch will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [3.12.3] - 2026-03-15
Post-v3.12.2 work on `main` adds a Tool Search domain restriction bypass, improves proxy compatibility for OpenAI o-series models and gzip compression, and delivers robustness fixes for Skills import, provider forms, and terminal session restore.
**Stats**: 13 commits | 47 files changed | +1,764 insertions | -122 deletions
### Added
- **Tool Search Domain Bypass**: Added setting to bypass Claude CLI Tool Search domain whitelist via equal-length binary patching; backups stored in `~/.cc-switch/toolsearch-backups/` with auto-reapply on startup when enabled
### Changed
- **Proxy Gzip Compression**: Non-streaming proxy requests now auto-negotiate gzip compression instead of forcing `identity`; streaming requests conservatively keep `identity` to avoid SSE decompression errors
- **o1/o3 Model Compatibility**: Chat Completions proxy forwarding now correctly uses `max_completion_tokens` instead of `max_tokens` for OpenAI o-series models such as o1/o3/o4-mini (#1451)
- **OpenCode Model Variants**: Placed OpenCode model variants at top level instead of inside options for better discoverability (#1317)
- **Skills Import Flow**: Replaced implicit filesystem-based app inference with explicit `ImportSkillSelection` to prevent incorrect multi-app activation; added reconciliation to remove disabled/orphaned symlinks and MCP servers from live config
### Fixed
- **o-series Responses API Tokens**: Kept Responses API on the correct `max_output_tokens` field for o-series models instead of incorrectly injecting `max_completion_tokens`
- **Provider Form Double Submit**: Prevented duplicate submissions on rapid button clicks in provider add/edit forms (#1352)
- **Ghostty Session Restore**: Fixed Claude session restore in Ghostty terminal (#1506, thanks @canyonsehun)
- **Skill ZIP Import Extension**: Added `.skill` file extension support in ZIP import dialog (#1240, #1455)
- **Skill ZIP Install Target App**: ZIP skill installs now use the currently active app instead of always defaulting to Claude
- **OpenClaw Active Card Highlight**: Fixed active OpenClaw provider card not being highlighted (#1419)
- **Responsive Layout with TOC**: Improved responsive design when TOC title exists (#1491)
---
## [3.12.2] - 2026-03-12
Post-v3.12.1 work focuses on Common Config safety during proxy takeover and more reliable Codex TOML editing.
+3 -2
View File
@@ -4,7 +4,7 @@
### The All-in-One Manager for Claude Code, Codex, Gemini CLI, OpenCode & OpenClaw
[![Version](https://img.shields.io/badge/version-3.12.1-blue.svg)](https://github.com/farion1231/cc-switch/releases)
[![Version](https://img.shields.io/badge/version-3.12.3-blue.svg)](https://github.com/farion1231/cc-switch/releases)
[![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-lightgrey.svg)](https://github.com/farion1231/cc-switch/releases)
[![Built with Tauri](https://img.shields.io/badge/built%20with-Tauri%202-orange.svg)](https://tauri.app/)
[![Downloads](https://img.shields.io/endpoint?url=https://api.pinstudios.net/api/badges/downloads/farion1231/cc-switch/total)](https://github.com/farion1231/cc-switch/releases/latest)
@@ -126,7 +126,7 @@ Modern AI-powered coding relies on CLI tools like Claude Code, Codex, Gemini CLI
## Features
[Full Changelog](CHANGELOG.md) | [Release Notes](docs/release-notes/v3.12.1-en.md)
[Full Changelog](CHANGELOG.md) | [Release Notes](docs/release-notes/v3.12.3-en.md)
### Provider Management
@@ -211,6 +211,7 @@ Add an official provider from the preset list. After switching to it, run the Lo
- **Local settings**: `~/.cc-switch/settings.json` (device-level UI preferences)
- **Backups**: `~/.cc-switch/backups/` (auto-rotated, keeps 10 most recent)
- **Skills**: `~/.cc-switch/skills/` (symlinked to corresponding apps by default)
- **Skill Backups**: `~/.cc-switch/skill-backups/` (created automatically before uninstall, keeps 20 most recent)
</details>
+3 -2
View File
@@ -4,7 +4,7 @@
### Claude Code、Codex、Gemini CLI、OpenCode、OpenClaw のオールインワン管理ツール
[![Version](https://img.shields.io/badge/version-3.12.1-blue.svg)](https://github.com/farion1231/cc-switch/releases)
[![Version](https://img.shields.io/badge/version-3.12.3-blue.svg)](https://github.com/farion1231/cc-switch/releases)
[![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-lightgrey.svg)](https://github.com/farion1231/cc-switch/releases)
[![Built with Tauri](https://img.shields.io/badge/built%20with-Tauri%202-orange.svg)](https://tauri.app/)
[![Downloads](https://img.shields.io/endpoint?url=https://api.pinstudios.net/api/badges/downloads/farion1231/cc-switch/total)](https://github.com/farion1231/cc-switch/releases/latest)
@@ -126,7 +126,7 @@ Claude Code / Codex / Gemini 公式チャンネルが最安で元価格の 38% /
## 特長
[完全な更新履歴](CHANGELOG.md) | [リリースノート](docs/release-notes/v3.12.1-ja.md)
[完全な更新履歴](CHANGELOG.md) | [リリースノート](docs/release-notes/v3.12.3-ja.md)
### プロバイダ管理
@@ -211,6 +211,7 @@ CC Switch は「最小限の介入」という設計原則に従っています
- **ローカル設定**: `~/.cc-switch/settings.json`(デバイスレベルの UI 設定)
- **バックアップ**: `~/.cc-switch/backups/`(自動ローテーション、最新 10 件を保持)
- **Skills**: `~/.cc-switch/skills/`(デフォルトでシンボリックリンクにより対応アプリに接続)
- **Skill バックアップ**: `~/.cc-switch/skill-backups/`(アンインストール前に自動作成、最新 20 件を保持)
</details>
+3 -2
View File
@@ -4,7 +4,7 @@
### Claude Code、Codex、Gemini CLI、OpenCode 和 OpenClaw 的全方位管理工具
[![Version](https://img.shields.io/badge/version-3.12.1-blue.svg)](https://github.com/farion1231/cc-switch/releases)
[![Version](https://img.shields.io/badge/version-3.12.3-blue.svg)](https://github.com/farion1231/cc-switch/releases)
[![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-lightgrey.svg)](https://github.com/farion1231/cc-switch/releases)
[![Built with Tauri](https://img.shields.io/badge/built%20with-Tauri%202-orange.svg)](https://tauri.app/)
[![Downloads](https://img.shields.io/endpoint?url=https://api.pinstudios.net/api/badges/downloads/farion1231/cc-switch/total)](https://github.com/farion1231/cc-switch/releases/latest)
@@ -127,7 +127,7 @@ Claude Code / Codex / Gemini 官方渠道低至 3.8 / 0.2 / 0.9 折,充值更
## 功能特性
[完整更新日志](CHANGELOG.md) | [发布说明](docs/release-notes/v3.12.1-zh.md)
[完整更新日志](CHANGELOG.md) | [发布说明](docs/release-notes/v3.12.3-zh.md)
### 供应商管理
@@ -214,6 +214,7 @@ CC Switch 使用“通用配置片段”功能,在不同的供应商之间传
- **本地设置**`~/.cc-switch/settings.json`(设备级 UI 偏好设置)
- **备份**`~/.cc-switch/backups/`(自动轮换,保留最近 10 个)
- **SKILLS**`~/.cc-switch/skills/`(默认通过软链接连接到对应应用)
- **技能备份**`~/.cc-switch/skill-backups/`(卸载前自动创建,保留最近 20 个)
</details>
+151
View File
@@ -0,0 +1,151 @@
# CC Switch v3.12.3
> Tool Search Domain Bypass, Proxy Gzip & o-Series Compatibility, Skills Import Robustness
**[中文版 →](v3.12.3-zh.md) | [日本語版 →](v3.12.3-ja.md)**
---
## Overview
CC Switch v3.12.3 adds a Tool Search domain restriction bypass via binary patching, improves proxy compatibility for OpenAI o-series models and gzip compression, and delivers robustness fixes for Skills import, provider forms, and terminal session restore. The Skills import flow has been reworked from implicit filesystem inference to explicit app selection, eliminating incorrect multi-app activation bugs.
**Release Date**: 2026-03-15
**Update Scale**: 13 commits | 47 files changed | +1,764 / -122 lines
---
## Highlights
- **Tool Search domain bypass**: New setting to remove Claude CLI Tool Search domain whitelist via equal-length binary patching, with automatic backup and reapply on startup
- **Proxy gzip compression**: Non-streaming proxy requests now auto-negotiate gzip compression, reducing bandwidth usage
- **o-series model compatibility**: Chat Completions proxy correctly uses `max_completion_tokens` for o1/o3/o4-mini models; Responses API kept on the correct `max_output_tokens` field
- **Skills import rework**: Replaced implicit filesystem-based app inference with explicit `ImportSkillSelection` to prevent incorrect multi-app activation
- **Ghostty terminal support**: Fixed Claude session restore in Ghostty terminal
---
## New Features
### Tool Search Domain Bypass
Added a setting to bypass Claude CLI Tool Search domain whitelist restrictions.
- Resolves the active `claude` command from PATH and applies an equal-length byte patch to remove the domain whitelist check
- Backups stored in `~/.cc-switch/toolsearch-backups/` (SHA-256 of path) so they survive Claude Code version upgrades
- The patch auto-reapplies on app startup when the setting is enabled
- Frontend checks patch result and rolls back the setting on failure
---
## Changes
### Proxy Gzip Compression
Non-streaming proxy requests now support gzip compression for reduced bandwidth usage.
- Non-streaming requests let reqwest auto-negotiate gzip and transparently decompress responses
- Streaming requests conservatively keep `Accept-Encoding: identity` to avoid decompression errors on interrupted SSE streams
### o1/o3 Model Compatibility
Proxy forwarding now handles OpenAI o-series model token parameters correctly.
- Chat Completions path uses `max_completion_tokens` instead of `max_tokens` for o1/o3/o4-mini models (#1451)
- Responses API path kept on the correct `max_output_tokens` field instead of incorrectly injecting `max_completion_tokens`
### OpenCode Model Variants
- Placed OpenCode model variants at top level instead of inside options for better discoverability (#1317)
### Skills Import Flow
The Skills import flow has been reworked for correctness and cleanup.
- Replaced implicit filesystem-based app inference with explicit `ImportSkillSelection` to prevent incorrect multi-app activation when the same skill directory exists under multiple app paths
- Added reconciliation to `sync_to_app` to remove disabled/orphaned symlinks
- MCP `sync_all_enabled` now removes disabled servers from live config
- Schema migration preserves a snapshot of legacy app mappings to avoid lossy reconstruction
---
## Bug Fixes
### Provider Form Double Submit
- Prevented duplicate submissions on rapid button clicks in provider add/edit forms (#1352)
### Ghostty Session Restore
- Fixed Claude session restore in Ghostty terminal (#1506, thanks @canyonsehun)
### Skill ZIP Import Extension
- Added `.skill` file extension support in ZIP import dialog (#1240, #1455)
### Skill ZIP Install Target App
- ZIP skill installs now use the currently active app instead of always defaulting to Claude
### OpenClaw Active Card Highlight
- Fixed active OpenClaw provider card not being highlighted (#1419)
### Responsive Layout with TOC
- Improved responsive design when TOC title exists (#1491)
---
## Download & Installation
Visit [Releases](https://github.com/farion1231/cc-switch/releases/latest) to download the appropriate version.
### System Requirements
| System | Minimum Version | Architecture |
| ------- | ------------------------------- | ----------------------------------- |
| Windows | Windows 10 or later | x64 |
| macOS | macOS 10.15 (Catalina) or later | Intel (x64) / Apple Silicon (arm64) |
| Linux | See table below | x64 |
### Windows
| File | Description |
| ------------------------------------------ | ---------------------------------------------------- |
| `CC-Switch-v3.12.3-Windows.msi` | **Recommended** - MSI installer with auto-update |
| `CC-Switch-v3.12.3-Windows-Portable.zip` | Portable version, extract and run, no registry write |
### macOS
| File | Description |
| ---------------------------------- | -------------------------------------------------------------------- |
| `CC-Switch-v3.12.3-macOS.zip` | **Recommended** - Extract and drag to Applications, Universal Binary |
| `CC-Switch-v3.12.3-macOS.tar.gz` | For Homebrew installation and auto-update |
> **Note**: Since the author doesn't have an Apple Developer account, you may see an "unidentified developer" warning on first launch. Please close it, then go to "System Settings" -> "Privacy & Security" -> click "Open Anyway", and it will open normally afterwards.
### Homebrew (macOS)
```bash
brew tap farion1231/ccswitch
brew install --cask cc-switch
```
Update:
```bash
brew upgrade --cask cc-switch
```
### Linux
| Distribution | Recommended Format | Installation Method |
| --------------------------------------- | ------------------ | ---------------------------------------------------------------------- |
| Ubuntu / Debian / Linux Mint / Pop!\_OS | `.deb` | `sudo dpkg -i CC-Switch-*.deb` or `sudo apt install ./CC-Switch-*.deb` |
| Fedora / RHEL / CentOS / Rocky Linux | `.rpm` | `sudo rpm -i CC-Switch-*.rpm` or `sudo dnf install ./CC-Switch-*.rpm` |
| openSUSE | `.rpm` | `sudo zypper install ./CC-Switch-*.rpm` |
| Arch Linux / Manjaro | `.AppImage` | Add execute permission and run directly, or use AUR |
| Other distributions / Unsure | `.AppImage` | `chmod +x CC-Switch-*.AppImage && ./CC-Switch-*.AppImage` |
+151
View File
@@ -0,0 +1,151 @@
# CC Switch v3.12.3
> Tool Search ドメイン制限バイパス、プロキシ Gzip 圧縮と o シリーズモデル互換性、Skills インポートの堅牢性向上
**[中文版 →](v3.12.3-zh.md) | [English →](v3.12.3-en.md)**
---
## 概要
CC Switch v3.12.3 は、バイナリパッチによる Tool Search ドメインホワイトリスト制限のバイパス機能を追加し、OpenAI o シリーズモデルのプロキシ互換性と gzip 圧縮を改善し、Skills インポート、プロバイダーフォーム、ターミナルセッション復元の堅牢性を修正しました。Skills インポートフローがファイルシステムベースの暗黙的な推論から明示的なアプリ選択に変更され、複数アプリの誤った有効化バグが解消されました。
**リリース日**: 2026-03-15
**更新規模**: 13 commits | 47 files changed | +1,764 / -122 lines
---
## ハイライト
- **Tool Search ドメインバイパス**: 等長バイナリパッチで Claude CLI Tool Search のドメインホワイトリストチェックを削除する新設定。起動時に自動バックアップと再適用
- **プロキシ Gzip 圧縮**: 非ストリーミングプロキシリクエストが gzip 圧縮を自動ネゴシエーションし、帯域幅消費を削減
- **o シリーズモデル互換性**: Chat Completions プロキシが o1/o3/o4-mini モデルに `max_completion_tokens` を正しく使用。Responses API は正しい `max_output_tokens` フィールドを維持
- **Skills インポートの刷新**: ファイルシステムベースの暗黙的なアプリ推論を明示的な `ImportSkillSelection` に置き換え、複数アプリの誤った有効化を防止
- **Ghostty ターミナルサポート**: Ghostty ターミナルでの Claude セッション復元を修正
---
## 新機能
### Tool Search ドメイン制限バイパス
Claude CLI Tool Search のドメインホワイトリスト制限をバイパスする設定を追加しました。
- PATH からアクティブな `claude` コマンドを解決し、等長バイトパッチを適用してドメインホワイトリストチェックを削除
- バックアップは `~/.cc-switch/toolsearch-backups/`(パスの SHA-256)に保存され、Claude Code のバージョンアップグレード後も有効
- 設定が有効な場合、アプリ起動時にパッチを自動的に再適用
- フロントエンドがパッチ結果を確認し、失敗時に設定を自動ロールバック
---
## 変更
### プロキシ Gzip 圧縮
非ストリーミングプロキシリクエストが gzip 圧縮をサポートし、帯域幅消費を削減しました。
- 非ストリーミングリクエストは reqwest が gzip を自動ネゴシエーションし、レスポンスを透過的に解凍
- ストリーミングリクエストは中断された SSE ストリームの解凍エラーを避けるため、保守的に `Accept-Encoding: identity` を維持
### o1/o3 モデル互換性
プロキシ転送が OpenAI o シリーズモデルのトークンパラメータを正しく処理するようになりました。
- Chat Completions パスが o1/o3/o4-mini モデルに `max_tokens` の代わりに `max_completion_tokens` を使用 (#1451)
- Responses API パスが正しい `max_output_tokens` フィールドを維持し、`max_completion_tokens` の誤った注入を防止
### OpenCode モデルバリアント
- OpenCode のモデルバリアントを options 内部ではなくプリセットのトップレベルに配置し、発見しやすさを向上 (#1317)
### Skills インポートフロー
Skills インポートフローが正確性とクリーンアップのためにリワークされました。
- ファイルシステムベースの暗黙的なアプリ推論を明示的な `ImportSkillSelection` に置き換え、同じ skill ディレクトリが複数アプリパスに存在する場合の複数アプリ誤有効化を防止
- `sync_to_app` に調整ロジックを追加し、無効化/孤立したシンボリックリンクを削除
- MCP `sync_all_enabled` がライブ設定から無効化されたサーバーを削除するように改善
- スキーママイグレーションがレガシーアプリマッピングのスナップショットを保持し、損失のある再構築を回避
---
## バグ修正
### プロバイダーフォームの二重送信
- プロバイダー追加/編集フォームでの高速連続クリックによる重複送信を防止 (#1352)
### Ghostty ターミナルセッション復元
- Ghostty ターミナルでの Claude セッション復元の失敗を修正 (#1506@canyonsehun に感謝)
### Skill ZIP インポート拡張子
- ZIP インポートダイアログが `.skill` ファイル拡張子をサポートするように修正 (#1240, #1455)
### Skill ZIP インストール対象アプリ
- ZIP 方式でインストールされた skill が常に Claude をデフォルトにするのではなく、現在アクティブなアプリを使用するように修正
### OpenClaw アクティブカードのハイライト
- OpenClaw の現在アクティブなプロバイダーカードがハイライト表示されない問題を修正 (#1419)
### TOC 付きレスポンシブレイアウト
- TOC タイトルが存在する場合のレスポンシブデザインを改善 (#1491)
---
## ダウンロードとインストール
[Releases](https://github.com/farion1231/cc-switch/releases/latest) から適切なバージョンをダウンロードしてください。
### システム要件
| システム | 最小バージョン | アーキテクチャ |
| -------- | -------------------------------- | ----------------------------------- |
| Windows | Windows 10 以降 | x64 |
| macOS | macOS 10.15 (Catalina) 以降 | Intel (x64) / Apple Silicon (arm64) |
| Linux | 下表参照 | x64 |
### Windows
| ファイル | 説明 |
| ------------------------------------------ | ---------------------------------------------------- |
| `CC-Switch-v3.12.3-Windows.msi` | **推奨** - MSI インストーラー、自動更新対応 |
| `CC-Switch-v3.12.3-Windows-Portable.zip` | ポータブル版、解凍して実行、レジストリ書き込みなし |
### macOS
| ファイル | 説明 |
| ---------------------------------- | ----------------------------------------------------------------- |
| `CC-Switch-v3.12.3-macOS.zip` | **推奨** - 解凍して Applications にドラッグ、Universal Binary |
| `CC-Switch-v3.12.3-macOS.tar.gz` | Homebrew インストールと自動更新用 |
> **注意**: 作者が Apple Developer アカウントを持っていないため、初回起動時に「開発元を確認できません」という警告が表示される場合があります。一度閉じてから、「システム設定」→「プライバシーとセキュリティ」→「このまま開く」をクリックすると、その後は正常に開けます。
### Homebrew (macOS)
```bash
brew tap farion1231/ccswitch
brew install --cask cc-switch
```
更新:
```bash
brew upgrade --cask cc-switch
```
### Linux
| ディストリビューション | 推奨形式 | インストール方法 |
| --------------------------------------- | ----------- | ---------------------------------------------------------------------- |
| Ubuntu / Debian / Linux Mint / Pop!\_OS | `.deb` | `sudo dpkg -i CC-Switch-*.deb` または `sudo apt install ./CC-Switch-*.deb` |
| Fedora / RHEL / CentOS / Rocky Linux | `.rpm` | `sudo rpm -i CC-Switch-*.rpm` または `sudo dnf install ./CC-Switch-*.rpm` |
| openSUSE | `.rpm` | `sudo zypper install ./CC-Switch-*.rpm` |
| Arch Linux / Manjaro | `.AppImage` | 実行権限を追加して直接実行、または AUR を使用 |
| その他のディストリビューション / 不明 | `.AppImage` | `chmod +x CC-Switch-*.AppImage && ./CC-Switch-*.AppImage` |
+151
View File
@@ -0,0 +1,151 @@
# CC Switch v3.12.3
> Tool Search 域名限制绕过、代理 Gzip 压缩与 o 系列模型兼容性、Skills 导入健壮性提升
**[English →](v3.12.3-en.md) | [日本語版 →](v3.12.3-ja.md)**
---
## 概览
CC Switch v3.12.3 新增了通过二进制补丁绕过 Tool Search 域名白名单限制的功能,改进了代理对 OpenAI o 系列模型的兼容性和 gzip 压缩支持,并修复了 Skills 导入、供应商表单和终端会话恢复等方面的问题。Skills 导入流程从基于文件系统的隐式推断改为显式应用选择,消除了多应用错误激活的 bug。
**发布日期**2026-03-15
**更新规模**13 commits | 47 files changed | +1,764 / -122 lines
---
## 重点内容
- **Tool Search 域名绕过**:新增设置项,通过等长二进制补丁移除 Claude CLI Tool Search 域名白名单检查,启动时自动备份和重新应用
- **代理 Gzip 压缩**:非流式代理请求现在自动协商 gzip 压缩,减少带宽消耗
- **o 系列模型兼容性**Chat Completions 代理正确使用 `max_completion_tokens` 处理 o1/o3/o4-mini 模型;Responses API 保持使用正确的 `max_output_tokens` 字段
- **Skills 导入重构**:将基于文件系统的隐式应用推断替换为显式的 `ImportSkillSelection`,防止多应用错误激活
- **Ghostty 终端支持**:修复在 Ghostty 终端中恢复 Claude 会话的问题
---
## 新功能
### Tool Search 域名限制绕过
新增设置项,可绕过 Claude CLI Tool Search 的域名白名单限制。
- 从 PATH 中解析当前活跃的 `claude` 命令,应用等长字节补丁移除域名白名单检查
- 备份存储在 `~/.cc-switch/toolsearch-backups/`(以路径的 SHA-256 为文件名),Claude Code 升级后备份仍然有效
- 设置启用时,应用启动自动重新应用补丁
- 前端检查补丁结果,失败时自动回滚设置
---
## 变更
### 代理 Gzip 压缩
非流式代理请求现在支持 gzip 压缩,减少带宽消耗。
- 非流式请求允许 reqwest 自动协商 gzip 并透明解压响应
- 流式请求保守地保持 `Accept-Encoding: identity`,避免中断的 SSE 流解压出错
### o1/o3 模型兼容性
代理转发现在正确处理 OpenAI o 系列模型的 token 参数。
- Chat Completions 路径对 o1/o3/o4-mini 模型使用 `max_completion_tokens` 替代 `max_tokens` (#1451)
- Responses API 路径保持使用正确的 `max_output_tokens` 字段,不再错误注入 `max_completion_tokens`
### OpenCode 模型变体
- 将 OpenCode 的模型变体放在预设顶层而非嵌套在 options 内部,提升可发现性 (#1317)
### Skills 导入流程
Skills 导入流程经过重构,提升正确性和清理能力。
- 将基于文件系统的隐式应用推断替换为显式的 `ImportSkillSelection`,防止同一 skill 目录存在于多个应用路径下时错误激活多个应用
-`sync_to_app` 增加协调逻辑,移除已禁用/孤立的符号链接
- MCP `sync_all_enabled` 现在会从 live 配置中移除已禁用的服务器
- 数据库迁移保留旧版应用映射快照,避免有损重建
---
## Bug 修复
### 供应商表单防重复提交
- 修复快速连续点击按钮时供应商添加/编辑表单重复提交的问题 (#1352)
### Ghostty 终端会话恢复
- 修复在 Ghostty 终端中恢复 Claude 会话失败的问题 (#1506,感谢 @canyonsehun)
### Skill ZIP 导入扩展名
- ZIP 导入对话框现在支持 `.skill` 文件扩展名 (#1240, #1455)
### Skill ZIP 安装目标应用
- ZIP 方式安装的 skill 现在使用当前活跃应用,而非始终默认为 Claude
### OpenClaw 活跃供应商高亮
- 修复 OpenClaw 当前激活的供应商卡片未高亮显示的问题 (#1419)
### 响应式布局与 TOC
- 改善存在 TOC 标题时的响应式布局 (#1491)
---
## 下载与安装
访问 [Releases](https://github.com/farion1231/cc-switch/releases/latest) 下载对应版本。
### 系统要求
| 系统 | 最低版本 | 架构 |
| ------- | ----------------------------- | ----------------------------------- |
| Windows | Windows 10 及以上 | x64 |
| macOS | macOS 10.15 (Catalina) 及以上 | Intel (x64) / Apple Silicon (arm64) |
| Linux | 见下表 | x64 |
### Windows
| 文件 | 说明 |
| ------------------------------------------ | ----------------------------------- |
| `CC-Switch-v3.12.3-Windows.msi` | **推荐** - MSI 安装包,支持自动更新 |
| `CC-Switch-v3.12.3-Windows-Portable.zip` | 便携版,解压即用,不写入注册表 |
### macOS
| 文件 | 说明 |
| ---------------------------------- | --------------------------------------------------------- |
| `CC-Switch-v3.12.3-macOS.zip` | **推荐** - 解压后拖入 Applications 即可,Universal Binary |
| `CC-Switch-v3.12.3-macOS.tar.gz` | 用于 Homebrew 安装和自动更新 |
> **注意**:由于作者没有苹果开发者账号,首次打开可能出现"未知开发者"警告,请先关闭,然后前往"系统设置" → "隐私与安全性" → 点击"仍要打开",之后便可以正常打开
### HomebrewmacOS
```bash
brew tap farion1231/ccswitch
brew install --cask cc-switch
```
更新:
```bash
brew upgrade --cask cc-switch
```
### Linux
| 发行版 | 推荐格式 | 安装方式 |
| --------------------------------------- | ----------- | ---------------------------------------------------------------------- |
| Ubuntu / Debian / Linux Mint / Pop!\_OS | `.deb` | `sudo dpkg -i CC-Switch-*.deb``sudo apt install ./CC-Switch-*.deb` |
| Fedora / RHEL / CentOS / Rocky Linux | `.rpm` | `sudo rpm -i CC-Switch-*.rpm``sudo dnf install ./CC-Switch-*.rpm` |
| openSUSE | `.rpm` | `sudo zypper install ./CC-Switch-*.rpm` |
| Arch Linux / Manjaro | `.AppImage` | 添加执行权限后直接运行,或使用 AUR |
| 其他发行版 / 不确定 | `.AppImage` | `chmod +x CC-Switch-*.AppImage && ./CC-Switch-*.AppImage` |
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "cc-switch",
"version": "3.12.2",
"version": "3.12.3",
"description": "All-in-One Assistant for Claude Code, Codex & Gemini CLI",
"type": "module",
"scripts": {
+1 -1
View File
@@ -684,7 +684,7 @@ dependencies = [
[[package]]
name = "cc-switch"
version = "3.12.2"
version = "3.12.3"
dependencies = [
"anyhow",
"async-stream",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "cc-switch"
version = "3.12.2"
version = "3.12.3"
description = "All-in-One Assistant for Claude Code, Codex & Gemini CLI"
authors = ["Jason Young"]
license = "MIT"
+12 -9
View File
@@ -7,7 +7,7 @@
use crate::app_config::{AppType, InstalledSkill, UnmanagedSkill};
use crate::error::format_skill_error;
use crate::services::skill::{
DiscoverableSkill, ImportSkillSelection, Skill, SkillRepo, SkillService,
DiscoverableSkill, ImportSkillSelection, Skill, SkillRepo, SkillService, SkillUninstallResult,
};
use crate::store::AppState;
use std::sync::Arc;
@@ -58,9 +58,11 @@ pub async fn install_skill_unified(
/// 卸载 Skill(新版统一卸载)
#[tauri::command]
pub fn uninstall_skill_unified(id: String, app_state: State<'_, AppState>) -> Result<bool, String> {
SkillService::uninstall(&app_state.db, &id).map_err(|e| e.to_string())?;
Ok(true)
pub fn uninstall_skill_unified(
id: String,
app_state: State<'_, AppState>,
) -> Result<SkillUninstallResult, String> {
SkillService::uninstall(&app_state.db, &id).map_err(|e| e.to_string())
}
/// 切换 Skill 的应用启用状态
@@ -194,7 +196,10 @@ pub async fn install_skill_for_app(
/// 卸载技能(兼容旧 API
#[tauri::command]
pub fn uninstall_skill(directory: String, app_state: State<'_, AppState>) -> Result<bool, String> {
pub fn uninstall_skill(
directory: String,
app_state: State<'_, AppState>,
) -> Result<SkillUninstallResult, String> {
uninstall_skill_for_app("claude".to_string(), directory, app_state)
}
@@ -204,7 +209,7 @@ pub fn uninstall_skill_for_app(
app: String,
directory: String,
app_state: State<'_, AppState>,
) -> Result<bool, String> {
) -> Result<SkillUninstallResult, String> {
let _ = parse_app_type(&app)?; // 验证参数
// 通过 directory 找到对应的 skill id
@@ -215,9 +220,7 @@ pub fn uninstall_skill_for_app(
.find(|s| s.directory.eq_ignore_ascii_case(&directory))
.ok_or_else(|| format!("未找到已安装的 Skill: {directory}"))?;
SkillService::uninstall(&app_state.db, &skill.id).map_err(|e| e.to_string())?;
Ok(true)
SkillService::uninstall(&app_state.db, &skill.id).map_err(|e| e.to_string())
}
// ========== 仓库管理命令 ==========
+156 -3
View File
@@ -152,6 +152,24 @@ impl Default for SkillStore {
}
}
/// Skill 卸载结果
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SkillUninstallResult {
#[serde(skip_serializing_if = "Option::is_none")]
pub backup_path: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct SkillBackupMetadata {
skill: InstalledSkill,
backup_created_at: i64,
source_path: String,
}
const SKILL_BACKUP_RETAIN_COUNT: usize = 20;
/// 技能元数据 (从 SKILL.md 解析)
#[derive(Debug, Clone, Deserialize)]
pub struct SkillMetadata {
@@ -369,6 +387,13 @@ impl SkillService {
Ok(dir)
}
/// 获取 Skill 卸载备份目录(~/.cc-switch/skill-backups/
fn get_backup_dir() -> Result<PathBuf> {
let dir = get_app_config_dir().join("skill-backups");
fs::create_dir_all(&dir)?;
Ok(dir)
}
/// 获取应用的 skills 目录
pub fn get_app_skills_dir(app: &AppType) -> Result<PathBuf> {
// 目录覆盖:优先使用用户在 settings.json 中配置的 override 目录
@@ -636,12 +661,15 @@ impl SkillService {
/// 1. 从所有应用目录删除
/// 2. 从 SSOT 删除
/// 3. 从数据库删除
pub fn uninstall(db: &Arc<Database>, id: &str) -> Result<()> {
pub fn uninstall(db: &Arc<Database>, id: &str) -> Result<SkillUninstallResult> {
// 获取 skill 信息
let skill = db
.get_installed_skill(id)?
.ok_or_else(|| anyhow!("Skill not found: {id}"))?;
let backup_path =
Self::create_uninstall_backup(&skill)?.map(|path| path.to_string_lossy().to_string());
// 从所有应用目录删除
for app in AppType::all() {
let _ = Self::remove_from_app(&skill.directory, &app);
@@ -657,9 +685,16 @@ impl SkillService {
// 从数据库删除
db.delete_skill(id)?;
log::info!("Skill {} 卸载成功", skill.name);
log::info!(
"Skill {} 卸载成功{}",
skill.name,
backup_path
.as_deref()
.map(|path| format!(", backup: {path}"))
.unwrap_or_default()
);
Ok(())
Ok(SkillUninstallResult { backup_path })
}
/// 切换应用启用状态
@@ -1500,6 +1535,124 @@ impl SkillService {
Ok(())
}
fn resolve_uninstall_backup_source(skill: &InstalledSkill) -> Result<Option<PathBuf>> {
let ssot_path = Self::get_ssot_dir()?.join(&skill.directory);
if ssot_path.is_dir() {
return Ok(Some(ssot_path));
}
for app in AppType::all() {
let app_dir = match Self::get_app_skills_dir(&app) {
Ok(dir) => dir,
Err(_) => continue,
};
let candidate = app_dir.join(&skill.directory);
if candidate.is_dir() {
return Ok(Some(candidate));
}
}
Ok(None)
}
fn sanitize_backup_segment(segment: &str) -> String {
let sanitized = segment
.chars()
.map(|c| match c {
'a'..='z' | 'A'..='Z' | '0'..='9' | '-' | '_' | '.' => c,
_ => '-',
})
.collect::<String>()
.trim_matches('-')
.to_string();
if sanitized.is_empty() {
"skill".to_string()
} else {
sanitized
}
}
fn cleanup_old_skill_backups(dir: &Path) -> Result<()> {
let mut entries = fs::read_dir(dir)?
.filter_map(|entry| entry.ok())
.filter_map(|entry| {
let metadata = entry.metadata().ok()?;
if !metadata.is_dir() {
return None;
}
Some((entry.path(), metadata.modified().ok()))
})
.collect::<Vec<_>>();
if entries.len() <= SKILL_BACKUP_RETAIN_COUNT {
return Ok(());
}
entries.sort_by_key(|(_, modified)| *modified);
let remove_count = entries.len().saturating_sub(SKILL_BACKUP_RETAIN_COUNT);
for (path, _) in entries.into_iter().take(remove_count) {
fs::remove_dir_all(&path)?;
}
Ok(())
}
fn create_uninstall_backup(skill: &InstalledSkill) -> Result<Option<PathBuf>> {
let Some(source_path) = Self::resolve_uninstall_backup_source(skill)? else {
log::warn!(
"Skill {} 卸载前未找到可备份的目录,将跳过备份",
skill.directory
);
return Ok(None);
};
let backup_root = Self::get_backup_dir()?;
let timestamp = Utc::now().format("%Y%m%d_%H%M%S");
let slug = Self::sanitize_backup_segment(&skill.directory);
let mut backup_path = backup_root.join(format!("{timestamp}_{slug}"));
let mut counter = 1;
while backup_path.exists() {
backup_path = backup_root.join(format!("{timestamp}_{slug}_{counter}"));
counter += 1;
}
let write_backup = || -> Result<()> {
let skill_backup_dir = backup_path.join("skill");
Self::copy_dir_recursive(&source_path, &skill_backup_dir)?;
let metadata = SkillBackupMetadata {
skill: skill.clone(),
backup_created_at: Utc::now().timestamp(),
source_path: source_path.to_string_lossy().to_string(),
};
let metadata_path = backup_path.join("meta.json");
let metadata_json = serde_json::to_string_pretty(&metadata)
.context("failed to serialize skill backup metadata")?;
fs::write(&metadata_path, metadata_json)
.with_context(|| format!("failed to write {}", metadata_path.display()))?;
Ok(())
};
if let Err(err) = write_backup() {
let _ = fs::remove_dir_all(&backup_path);
return Err(err);
}
if let Err(err) = Self::cleanup_old_skill_backups(&backup_root) {
log::warn!("清理旧 Skill 备份失败: {err:#}");
}
log::info!(
"Skill {} 已在卸载前备份到 {}",
skill.name,
backup_path.display()
);
Ok(Some(backup_path))
}
/// 解析 ZIP 中的符号链接:将目标内容复制到 symlink 位置
///
/// GitHub ZIP 归档保留了 symlink 元数据,解压时可通过 `is_symlink()` 检测。
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "CC Switch",
"version": "3.12.2",
"version": "3.12.3",
"identifier": "com.ccswitch.desktop",
"build": {
"frontendDist": "../dist",
+68
View File
@@ -125,6 +125,74 @@ fn sync_to_app_removes_disabled_and_orphaned_ssot_symlinks() {
);
}
#[test]
fn uninstall_skill_creates_backup_before_removing_ssot() {
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
let home = ensure_test_home();
let ssot_skill_dir = home.join(".cc-switch").join("skills").join("backup-skill");
write_skill(&ssot_skill_dir, "Backup Skill");
fs::write(ssot_skill_dir.join("prompt.md"), "backup me").expect("write prompt.md");
let state = create_test_state().expect("create test state");
state
.db
.save_skill(&InstalledSkill {
id: "local:backup-skill".to_string(),
name: "Backup Skill".to_string(),
description: Some("Back me up before uninstall".to_string()),
directory: "backup-skill".to_string(),
repo_owner: None,
repo_name: None,
repo_branch: None,
readme_url: None,
apps: SkillApps {
claude: true,
codex: false,
gemini: false,
opencode: false,
},
installed_at: 123,
})
.expect("save skill");
let result = SkillService::uninstall(&state.db, "local:backup-skill").expect("uninstall skill");
let backup_path = result.backup_path.expect("backup path should be returned");
let backup_dir = std::path::PathBuf::from(&backup_path);
assert!(backup_dir.exists(), "backup directory should exist");
assert!(
backup_dir.join("skill").join("SKILL.md").exists(),
"backup should include SKILL.md"
);
assert_eq!(
fs::read_to_string(backup_dir.join("skill").join("prompt.md"))
.expect("read backed up prompt"),
"backup me"
);
let metadata: serde_json::Value = serde_json::from_str(
&fs::read_to_string(backup_dir.join("meta.json")).expect("read backup metadata"),
)
.expect("parse backup metadata");
assert_eq!(metadata["skill"]["directory"], "backup-skill");
assert_eq!(metadata["skill"]["name"], "Backup Skill");
assert!(
!ssot_skill_dir.exists(),
"SSOT skill directory should be removed after uninstall"
);
assert!(
state
.db
.get_installed_skill("local:backup-skill")
.expect("query skill")
.is_none(),
"database row should be deleted after uninstall"
);
}
#[test]
fn migration_snapshot_overrides_multi_source_directory_inference() {
let _guard = test_mutex().lock().expect("acquire test mutex");
+4 -1
View File
@@ -80,9 +80,12 @@ const UnifiedSkillsPanel = React.forwardRef<
message: t("skills.uninstallConfirm", { name: skill.name }),
onConfirm: async () => {
try {
await uninstallMutation.mutateAsync(skill.id);
const result = await uninstallMutation.mutateAsync(skill.id);
setConfirmDialog(null);
toast.success(t("skills.uninstallSuccess", { name: skill.name }), {
description: result.backupPath
? t("skills.backup.location", { path: result.backupPath })
: undefined,
closeButton: true,
});
} catch (error) {
+4 -1
View File
@@ -1564,9 +1564,12 @@
"noUnmanagedFound": "No skills to import found. All skills are already managed by CC Switch.",
"foundIn": "Found in",
"local": "Local",
"uninstallConfirm": "Are you sure you want to uninstall \"{{name}}\"? This will remove the skill from all apps.",
"uninstallConfirm": "Are you sure you want to uninstall \"{{name}}\"? This will remove the skill from all apps and create a local backup first.",
"uninstallInMainPanel": "Please uninstall skills from the main panel",
"notFound": "Skill not found",
"backup": {
"location": "Backup location: {{path}}"
},
"apps": {
"claude": "Claude",
"codex": "Codex",
+4 -1
View File
@@ -1564,9 +1564,12 @@
"noUnmanagedFound": "インポートするスキルが見つかりませんでした。すべてのスキルは CC Switch で管理されています。",
"foundIn": "発見場所",
"local": "ローカル",
"uninstallConfirm": "「{{name}}」をアンインストールしますか?すべてのアプリからこのスキルが削除されます。",
"uninstallConfirm": "「{{name}}」をアンインストールしますか?すべてのアプリからこのスキルが削除され、削除前にローカルバックアップが自動作成されます。",
"uninstallInMainPanel": "メインパネルからスキルをアンインストールしてください",
"notFound": "スキルが見つかりません",
"backup": {
"location": "バックアップ場所: {{path}}"
},
"apps": {
"claude": "Claude",
"codex": "Codex",
+4 -1
View File
@@ -1564,9 +1564,12 @@
"noUnmanagedFound": "未发现需要导入的技能。所有技能已在 CC Switch 统一管理中。",
"foundIn": "发现于",
"local": "本地",
"uninstallConfirm": "确定要卸载技能 \"{{name}}\" 吗?这将从所有应用中移除该技能。",
"uninstallConfirm": "确定要卸载技能 \"{{name}}\" 吗?这将从所有应用中移除该技能,并在删除前自动创建本地备份。",
"uninstallInMainPanel": "请在主面板中卸载技能",
"notFound": "未找到技能",
"backup": {
"location": "备份位置: {{path}}"
},
"apps": {
"claude": "Claude",
"codex": "Codex",
+9 -2
View File
@@ -27,6 +27,10 @@ export interface InstalledSkill {
installedAt: number;
}
export interface SkillUninstallResult {
backupPath?: string;
}
/** 可发现的 Skill(来自仓库) */
export interface DiscoverableSkill {
key: string;
@@ -94,7 +98,7 @@ export const skillsApi = {
},
/** 卸载 Skill(统一卸载) */
async uninstallUnified(id: string): Promise<boolean> {
async uninstallUnified(id: string): Promise<SkillUninstallResult> {
return await invoke("uninstall_skill_unified", { id });
},
@@ -139,7 +143,10 @@ export const skillsApi = {
},
/** 卸载技能(兼容旧 API) */
async uninstall(directory: string, app: AppId = "claude"): Promise<boolean> {
async uninstall(
directory: string,
app: AppId = "claude",
): Promise<SkillUninstallResult> {
if (app === "claude") {
return await invoke("uninstall_skill", { directory });
}
@@ -0,0 +1,94 @@
import { createRef } from "react";
import { render, screen, waitFor, act } from "@testing-library/react";
import { describe, expect, it, vi, beforeEach } from "vitest";
import UnifiedSkillsPanel, {
type UnifiedSkillsPanelHandle,
} from "@/components/skills/UnifiedSkillsPanel";
const scanUnmanagedMock = vi.fn();
const toggleSkillAppMock = vi.fn();
const uninstallSkillMock = vi.fn();
const importSkillsMock = vi.fn();
const installFromZipMock = vi.fn();
vi.mock("sonner", () => ({
toast: {
success: vi.fn(),
error: vi.fn(),
info: vi.fn(),
},
}));
vi.mock("@/hooks/useSkills", () => ({
useInstalledSkills: () => ({
data: [],
isLoading: false,
}),
useToggleSkillApp: () => ({
mutateAsync: toggleSkillAppMock,
}),
useUninstallSkill: () => ({
mutateAsync: uninstallSkillMock,
}),
useScanUnmanagedSkills: () => ({
data: [
{
directory: "shared-skill",
name: "Shared Skill",
description: "Imported from Claude",
foundIn: ["claude"],
path: "/tmp/shared-skill",
},
],
refetch: scanUnmanagedMock,
}),
useImportSkillsFromApps: () => ({
mutateAsync: importSkillsMock,
}),
useInstallSkillsFromZip: () => ({
mutateAsync: installFromZipMock,
}),
}));
describe("UnifiedSkillsPanel", () => {
beforeEach(() => {
scanUnmanagedMock.mockResolvedValue({
data: [
{
directory: "shared-skill",
name: "Shared Skill",
description: "Imported from Claude",
foundIn: ["claude"],
path: "/tmp/shared-skill",
},
],
});
toggleSkillAppMock.mockReset();
uninstallSkillMock.mockReset();
importSkillsMock.mockReset();
installFromZipMock.mockReset();
});
it("opens the import dialog without crashing when app toggles render", async () => {
const ref = createRef<UnifiedSkillsPanelHandle>();
render(
<UnifiedSkillsPanel
ref={ref}
onOpenDiscovery={() => {}}
currentApp="claude"
/>,
);
await act(async () => {
await ref.current?.openImport();
});
await waitFor(() => {
expect(screen.getByText("skills.import")).toBeInTheDocument();
expect(screen.getByText("Shared Skill")).toBeInTheDocument();
expect(screen.getByText("/tmp/shared-skill")).toBeInTheDocument();
});
});
});