diff --git a/src-tauri/src/claude_mcp.rs b/src-tauri/src/claude_mcp.rs index 0369e91cd..d17f75b01 100644 --- a/src-tauri/src/claude_mcp.rs +++ b/src-tauri/src/claude_mcp.rs @@ -105,6 +105,55 @@ pub fn read_mcp_json() -> Result, AppError> { Ok(Some(content)) } +/// 在 ~/.claude.json 根对象写入 hasCompletedOnboarding=true(用于跳过 Claude Code 初次安装确认) +/// 仅增量写入该字段,其他字段保持不变 +pub fn set_has_completed_onboarding() -> Result { + let path = user_config_path(); + let mut root = if path.exists() { + read_json_value(&path)? + } else { + serde_json::json!({}) + }; + + let obj = root + .as_object_mut() + .ok_or_else(|| AppError::Config("~/.claude.json 根必须是对象".into()))?; + + let already = obj + .get("hasCompletedOnboarding") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + if already { + return Ok(false); + } + + obj.insert("hasCompletedOnboarding".into(), Value::Bool(true)); + write_json_value(&path, &root)?; + Ok(true) +} + +/// 删除 ~/.claude.json 根对象的 hasCompletedOnboarding 字段(恢复 Claude Code 初次安装确认) +/// 仅增量删除该字段,其他字段保持不变 +pub fn clear_has_completed_onboarding() -> Result { + let path = user_config_path(); + if !path.exists() { + return Ok(false); + } + + let mut root = read_json_value(&path)?; + let obj = root + .as_object_mut() + .ok_or_else(|| AppError::Config("~/.claude.json 根必须是对象".into()))?; + + let existed = obj.remove("hasCompletedOnboarding").is_some(); + if !existed { + return Ok(false); + } + + write_json_value(&path, &root)?; + Ok(true) +} + pub fn upsert_mcp_server(id: &str, spec: Value) -> Result { if id.trim().is_empty() { return Err(AppError::InvalidInput("MCP 服务器 ID 不能为空".into())); diff --git a/src-tauri/src/commands/plugin.rs b/src-tauri/src/commands/plugin.rs index 30fab6dc2..31d803cef 100644 --- a/src-tauri/src/commands/plugin.rs +++ b/src-tauri/src/commands/plugin.rs @@ -34,3 +34,15 @@ pub async fn apply_claude_plugin_config(official: bool) -> Result pub async fn is_claude_plugin_applied() -> Result { crate::claude_plugin::is_claude_config_applied().map_err(|e| e.to_string()) } + +/// Claude Code:跳过初次安装确认(写入 ~/.claude.json 的 hasCompletedOnboarding=true) +#[tauri::command] +pub async fn apply_claude_onboarding_skip() -> Result { + crate::claude_mcp::set_has_completed_onboarding().map_err(|e| e.to_string()) +} + +/// Claude Code:恢复初次安装确认(删除 ~/.claude.json 的 hasCompletedOnboarding 字段) +#[tauri::command] +pub async fn clear_claude_onboarding_skip() -> Result { + crate::claude_mcp::clear_has_completed_onboarding().map_err(|e| e.to_string()) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index d139edac7..ea63c1611 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -556,6 +556,8 @@ pub fn run() { commands::read_claude_plugin_config, commands::apply_claude_plugin_config, commands::is_claude_plugin_applied, + commands::apply_claude_onboarding_skip, + commands::clear_claude_onboarding_skip, // Claude MCP management commands::get_claude_mcp_status, commands::read_claude_mcp_config, diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index bd239b5e7..30f4c5de9 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -31,6 +31,9 @@ pub struct AppSettings { /// 是否启用 Claude 插件联动 #[serde(default)] pub enable_claude_plugin_integration: bool, + /// 是否跳过 Claude Code 初次安装确认 + #[serde(default = "default_true")] + pub skip_claude_onboarding: bool, /// 是否开机自启 #[serde(default)] pub launch_on_startup: bool, @@ -65,12 +68,17 @@ fn default_minimize_to_tray_on_close() -> bool { true } +fn default_true() -> bool { + true +} + impl Default for AppSettings { fn default() -> Self { Self { show_in_tray: true, minimize_to_tray_on_close: true, enable_claude_plugin_integration: false, + skip_claude_onboarding: true, launch_on_startup: false, language: None, claude_config_dir: None, diff --git a/src/components/settings/WindowSettings.tsx b/src/components/settings/WindowSettings.tsx index 06f0d39a8..019ed8b0c 100644 --- a/src/components/settings/WindowSettings.tsx +++ b/src/components/settings/WindowSettings.tsx @@ -41,6 +41,13 @@ export function WindowSettings({ settings, onChange }: WindowSettingsProps) { onChange({ enableClaudePluginIntegration: value }) } /> + + onChange({ skipClaudeOnboarding: value })} + /> ); } diff --git a/src/hooks/useSettings.ts b/src/hooks/useSettings.ts index d6dd74583..cdcc6493b 100644 --- a/src/hooks/useSettings.ts +++ b/src/hooks/useSettings.ts @@ -160,6 +160,36 @@ export function useSettings(): UseSettingsResult { } } + // Claude Code 初次安装确认:开=写入 hasCompletedOnboarding=true;关=删除该字段 + // 仅在本次更新包含 skipClaudeOnboarding 时触发,避免其它自动保存误触发 + const nextSkipClaudeOnboarding = updates.skipClaudeOnboarding; + if ( + nextSkipClaudeOnboarding !== undefined && + nextSkipClaudeOnboarding !== (data?.skipClaudeOnboarding ?? false) + ) { + try { + if (nextSkipClaudeOnboarding) { + await settingsApi.applyClaudeOnboardingSkip(); + } else { + await settingsApi.clearClaudeOnboardingSkip(); + } + } catch (error) { + console.warn( + "[useSettings] Failed to sync Claude onboarding skip", + error, + ); + toast.error( + nextSkipClaudeOnboarding + ? t("notifications.skipClaudeOnboardingFailed", { + defaultValue: "跳过 Claude Code 初次安装确认失败", + }) + : t("notifications.clearClaudeOnboardingSkipFailed", { + defaultValue: "恢复 Claude Code 初次安装确认失败", + }), + ); + } + } + // 持久化语言偏好 try { if (typeof window !== "undefined" && updates.language) { @@ -242,6 +272,33 @@ export function useSettings(): UseSettingsResult { } } + // Claude Code 初次安装确认:开=写入 hasCompletedOnboarding=true;关=删除该字段 + const prevSkipClaudeOnboarding = data?.skipClaudeOnboarding ?? false; + const nextSkipClaudeOnboarding = payload.skipClaudeOnboarding ?? false; + if (nextSkipClaudeOnboarding !== prevSkipClaudeOnboarding) { + try { + if (nextSkipClaudeOnboarding) { + await settingsApi.applyClaudeOnboardingSkip(); + } else { + await settingsApi.clearClaudeOnboardingSkip(); + } + } catch (error) { + console.warn( + "[useSettings] Failed to sync Claude onboarding skip", + error, + ); + toast.error( + nextSkipClaudeOnboarding + ? t("notifications.skipClaudeOnboardingFailed", { + defaultValue: "跳过 Claude Code 初次安装确认失败", + }) + : t("notifications.clearClaudeOnboardingSkipFailed", { + defaultValue: "恢复 Claude Code 初次安装确认失败", + }), + ); + } + } + // 只在 Claude 插件集成状态真正改变时调用系统 API if ( payload.enableClaudePluginIntegration !== undefined && diff --git a/src/hooks/useSettingsForm.ts b/src/hooks/useSettingsForm.ts index c823992bf..f4f091622 100644 --- a/src/hooks/useSettingsForm.ts +++ b/src/hooks/useSettingsForm.ts @@ -83,6 +83,7 @@ export function useSettingsForm(): UseSettingsFormResult { minimizeToTrayOnClose: data.minimizeToTrayOnClose ?? true, enableClaudePluginIntegration: data.enableClaudePluginIntegration ?? false, + skipClaudeOnboarding: data.skipClaudeOnboarding ?? true, claudeConfigDir: sanitizeDir(data.claudeConfigDir), codexConfigDir: sanitizeDir(data.codexConfigDir), language: normalizedLanguage, @@ -102,6 +103,7 @@ export function useSettingsForm(): UseSettingsFormResult { showInTray: true, minimizeToTrayOnClose: true, enableClaudePluginIntegration: false, + skipClaudeOnboarding: true, language: readPersistedLanguage(), } as SettingsFormState); @@ -136,6 +138,7 @@ export function useSettingsForm(): UseSettingsFormResult { minimizeToTrayOnClose: serverData.minimizeToTrayOnClose ?? true, enableClaudePluginIntegration: serverData.enableClaudePluginIntegration ?? false, + skipClaudeOnboarding: serverData.skipClaudeOnboarding ?? true, claudeConfigDir: sanitizeDir(serverData.claudeConfigDir), codexConfigDir: sanitizeDir(serverData.codexConfigDir), language: normalizedLanguage, diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index a606363dc..9721bb00e 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -122,6 +122,8 @@ "appliedToClaudePlugin": "Applied to Claude plugin", "removedFromClaudePlugin": "Removed from Claude plugin", "syncClaudePluginFailed": "Sync Claude plugin failed", + "skipClaudeOnboardingFailed": "Failed to skip Claude Code first-run confirmation", + "clearClaudeOnboardingSkipFailed": "Failed to restore Claude Code first-run confirmation", "updateSuccess": "Provider updated successfully", "updateFailed": "Failed to update provider: {{error}}", "deleteSuccess": "Provider deleted", @@ -175,6 +177,8 @@ "minimizeToTrayDescription": "When checked, clicking the close button will hide to system tray, otherwise the app will exit directly.", "enableClaudePluginIntegration": "Apply to Claude Code extension", "enableClaudePluginIntegrationDescription": "When enabled, the VS Code Claude Code extension provider will switch with this app", + "skipClaudeOnboarding": "Skip Claude Code first-run confirmation", + "skipClaudeOnboardingDescription": "When enabled, Claude Code will skip the first-run confirmation", "configDirectoryOverride": "Configuration Directory Override (Advanced)", "configDirectoryDescription": "When using Claude Code or Codex in environments like WSL, you can manually specify the configuration directory to the one in WSL to keep provider data consistent with the main environment.", "appConfigDir": "CC Switch Configuration Directory", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 2ad3c1d62..b9a53c53b 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -122,6 +122,8 @@ "appliedToClaudePlugin": "Claude プラグインに適用しました", "removedFromClaudePlugin": "Claude プラグインから削除しました", "syncClaudePluginFailed": "Claude プラグインとの同期に失敗しました", + "skipClaudeOnboardingFailed": "Claude Code の初回確認スキップに失敗しました", + "clearClaudeOnboardingSkipFailed": "Claude Code の初回確認の復元に失敗しました", "updateSuccess": "プロバイダーを更新しました", "updateFailed": "プロバイダーの更新に失敗しました: {{error}}", "deleteSuccess": "プロバイダーを削除しました", @@ -175,6 +177,8 @@ "minimizeToTrayDescription": "チェックすると閉じるボタンでトレイに隠し、オフならアプリを終了します。", "enableClaudePluginIntegration": "Claude Code 拡張に適用", "enableClaudePluginIntegrationDescription": "オンにすると VS Code の Claude Code 拡張のプロバイダーも同期します", + "skipClaudeOnboarding": "Claude Code の初回確認をスキップ", + "skipClaudeOnboardingDescription": "オンにすると Claude Code の初回インストール確認をスキップします", "configDirectoryOverride": "設定ディレクトリの上書き(詳細)", "configDirectoryDescription": "WSL などで Claude Code や Codex を使う場合、ここで設定ディレクトリを WSL 側に合わせるとデータを揃えられます。", "appConfigDir": "CC Switch 設定ディレクトリ", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 1b66111af..ae0c7159c 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -122,6 +122,8 @@ "appliedToClaudePlugin": "已应用到 Claude 插件", "removedFromClaudePlugin": "已从 Claude 插件移除", "syncClaudePluginFailed": "同步 Claude 插件失败", + "skipClaudeOnboardingFailed": "跳过 Claude Code 初次安装确认失败", + "clearClaudeOnboardingSkipFailed": "恢复 Claude Code 初次安装确认失败", "updateSuccess": "供应商更新成功", "updateFailed": "更新供应商失败:{{error}}", "deleteSuccess": "供应商已删除", @@ -175,6 +177,8 @@ "minimizeToTrayDescription": "勾选后点击关闭按钮会隐藏到系统托盘,取消则直接退出应用。", "enableClaudePluginIntegration": "应用到 Claude Code 插件", "enableClaudePluginIntegrationDescription": "开启后 Vscode Claude Code 插件的供应商将随本软件切换", + "skipClaudeOnboarding": "跳过 Claude Code 初次安装确认", + "skipClaudeOnboardingDescription": "开启后跳过 Claude Code 初次安装确认", "configDirectoryOverride": "配置目录覆盖(高级)", "configDirectoryDescription": "在 WSL 等环境使用 Claude Code 或 Codex 的时候,可手动指定为 WSL 里的配置目录,供应商数据与主环境保持一致。", "appConfigDir": "CC Switch 配置目录", diff --git a/src/lib/api/settings.ts b/src/lib/api/settings.ts index 8922d8b52..3edcb4bb6 100644 --- a/src/lib/api/settings.ts +++ b/src/lib/api/settings.ts @@ -69,6 +69,14 @@ export const settingsApi = { return await invoke("apply_claude_plugin_config", { official }); }, + async applyClaudeOnboardingSkip(): Promise { + return await invoke("apply_claude_onboarding_skip"); + }, + + async clearClaudeOnboardingSkip(): Promise { + return await invoke("clear_claude_onboarding_skip"); + }, + async saveFileDialog(defaultName: string): Promise { return await invoke("save_file_dialog", { defaultName }); }, diff --git a/src/lib/schemas/settings.ts b/src/lib/schemas/settings.ts index 61e6b9da6..91709830e 100644 --- a/src/lib/schemas/settings.ts +++ b/src/lib/schemas/settings.ts @@ -12,6 +12,7 @@ export const settingsSchema = z.object({ showInTray: z.boolean(), minimizeToTrayOnClose: z.boolean(), enableClaudePluginIntegration: z.boolean().optional(), + skipClaudeOnboarding: z.boolean().optional(), launchOnStartup: z.boolean().optional(), language: z.enum(["en", "zh", "ja"]).optional(), diff --git a/src/types.ts b/src/types.ts index bdf46fcf9..08815981d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -106,6 +106,8 @@ export interface Settings { minimizeToTrayOnClose: boolean; // 启用 Claude 插件联动(写入 ~/.claude/config.json 的 primaryApiKey) enableClaudePluginIntegration?: boolean; + // 跳过 Claude Code 初次安装确认(写入 ~/.claude.json 的 hasCompletedOnboarding) + skipClaudeOnboarding?: boolean; // 是否开机自启 launchOnStartup?: boolean; // 首选语言(可选,默认中文) diff --git a/tests/components/ImportExportSection.test.tsx b/tests/components/ImportExportSection.test.tsx index 1121699fc..948c3fcd7 100644 --- a/tests/components/ImportExportSection.test.tsx +++ b/tests/components/ImportExportSection.test.tsx @@ -63,7 +63,7 @@ describe("ImportExportSection Component", () => { fireEvent.click(importButton); expect(baseProps.onImport).toHaveBeenCalledTimes(1); - fireEvent.click(screen.getByRole("button", { name: "Clear selection" })); + fireEvent.click(screen.getByRole("button", { name: "common.clear" })); expect(baseProps.onClear).toHaveBeenCalledTimes(1); }); diff --git a/tests/hooks/useSettings.test.tsx b/tests/hooks/useSettings.test.tsx index a30350117..2d5ef42f1 100644 --- a/tests/hooks/useSettings.test.tsx +++ b/tests/hooks/useSettings.test.tsx @@ -7,6 +7,8 @@ const mutateAsyncMock = vi.fn(); const useSettingsQueryMock = vi.fn(); const setAppConfigDirOverrideMock = vi.fn(); const applyClaudePluginConfigMock = vi.fn(); +const applyClaudeOnboardingSkipMock = vi.fn(); +const clearClaudeOnboardingSkipMock = vi.fn(); const syncCurrentProvidersLiveMock = vi.fn(); const updateTrayMenuMock = vi.fn(); const toastErrorMock = vi.fn(); @@ -50,6 +52,10 @@ vi.mock("@/lib/api", () => ({ setAppConfigDirOverrideMock(...args), applyClaudePluginConfig: (...args: unknown[]) => applyClaudePluginConfigMock(...args), + applyClaudeOnboardingSkip: (...args: unknown[]) => + applyClaudeOnboardingSkipMock(...args), + clearClaudeOnboardingSkip: (...args: unknown[]) => + clearClaudeOnboardingSkipMock(...args), syncCurrentProvidersLive: (...args: unknown[]) => syncCurrentProvidersLiveMock(...args), }, @@ -63,6 +69,7 @@ const createSettingsFormMock = (overrides: Record = {}) => ({ showInTray: true, minimizeToTrayOnClose: true, enableClaudePluginIntegration: false, + skipClaudeOnboarding: true, claudeConfigDir: "/claude", codexConfigDir: "/codex", language: "zh", @@ -111,6 +118,8 @@ describe("useSettings hook", () => { useSettingsQueryMock.mockReset(); setAppConfigDirOverrideMock.mockReset(); applyClaudePluginConfigMock.mockReset(); + applyClaudeOnboardingSkipMock.mockReset(); + clearClaudeOnboardingSkipMock.mockReset(); syncCurrentProvidersLiveMock.mockReset(); toastErrorMock.mockReset(); toastSuccessMock.mockReset(); @@ -120,6 +129,7 @@ describe("useSettings hook", () => { showInTray: true, minimizeToTrayOnClose: true, enableClaudePluginIntegration: false, + skipClaudeOnboarding: true, claudeConfigDir: "/server/claude", codexConfigDir: "/server/codex", language: "zh", @@ -142,6 +152,64 @@ describe("useSettings hook", () => { mutateAsyncMock.mockResolvedValue(true); setAppConfigDirOverrideMock.mockResolvedValue(true); applyClaudePluginConfigMock.mockResolvedValue(true); + applyClaudeOnboardingSkipMock.mockResolvedValue(true); + clearClaudeOnboardingSkipMock.mockResolvedValue(true); + }); + + it("auto-saves and applies Claude onboarding skip when toggled on", async () => { + serverSettings = { + ...serverSettings, + skipClaudeOnboarding: false, + }; + useSettingsQueryMock.mockReturnValue({ + data: serverSettings, + isLoading: false, + }); + + settingsFormMock = createSettingsFormMock({ + settings: { + ...serverSettings, + language: "zh", + skipClaudeOnboarding: false, + }, + }); + + const { result } = renderHook(() => useSettings()); + + await act(async () => { + await result.current.autoSaveSettings({ skipClaudeOnboarding: true }); + }); + + expect(applyClaudeOnboardingSkipMock).toHaveBeenCalledTimes(1); + expect(toastErrorMock).not.toHaveBeenCalled(); + }); + + it("auto-saves and clears Claude onboarding skip when toggled off", async () => { + serverSettings = { + ...serverSettings, + skipClaudeOnboarding: true, + }; + useSettingsQueryMock.mockReturnValue({ + data: serverSettings, + isLoading: false, + }); + + settingsFormMock = createSettingsFormMock({ + settings: { + ...serverSettings, + language: "zh", + skipClaudeOnboarding: true, + }, + }); + + const { result } = renderHook(() => useSettings()); + + await act(async () => { + await result.current.autoSaveSettings({ skipClaudeOnboarding: false }); + }); + + expect(clearClaudeOnboardingSkipMock).toHaveBeenCalledTimes(1); + expect(toastErrorMock).not.toHaveBeenCalled(); }); it("saves settings and flags restart when app config directory changes", async () => { diff --git a/tests/msw/handlers.ts b/tests/msw/handlers.ts index 4672fe9e3..cb6dd3e35 100644 --- a/tests/msw/handlers.ts +++ b/tests/msw/handlers.ts @@ -176,6 +176,10 @@ export const handlers = [ }, ), + http.post(`${TAURI_ENDPOINT}/apply_claude_onboarding_skip`, () => success(true)), + + http.post(`${TAURI_ENDPOINT}/clear_claude_onboarding_skip`, () => success(true)), + http.post(`${TAURI_ENDPOINT}/get_config_dir`, async ({ request }) => { const { app } = await withJson<{ app: AppId }>(request); return success(app === "claude" ? "/default/claude" : "/default/codex");