From b20a9371ad73f8e3bdc1342d9bbae9272ffb5d63 Mon Sep 17 00:00:00 2001 From: LeonardoTan19 <1903048407@qq.com> Date: Mon, 1 Jun 2026 14:38:58 +0800 Subject: [PATCH 1/6] chore(deeplink): port mcp/prompt/skill import handlers from upstream Pull src/deeplink/{mcp,prompt,skill}.rs from farion1231/cc-switch. These implement library-level deeplink import for resource types beyond provider; parser dispatch and CLI wiring follow in subsequent commits. --- src-tauri/src/deeplink/mcp.rs | 199 +++++++++++++++++++++++++++++++ src-tauri/src/deeplink/prompt.rs | 86 +++++++++++++ src-tauri/src/deeplink/skill.rs | 51 ++++++++ 3 files changed, 336 insertions(+) create mode 100644 src-tauri/src/deeplink/mcp.rs create mode 100644 src-tauri/src/deeplink/prompt.rs create mode 100644 src-tauri/src/deeplink/skill.rs diff --git a/src-tauri/src/deeplink/mcp.rs b/src-tauri/src/deeplink/mcp.rs new file mode 100644 index 00000000..822cd839 --- /dev/null +++ b/src-tauri/src/deeplink/mcp.rs @@ -0,0 +1,199 @@ +//! MCP server import from deep link +//! +//! Handles batch import of MCP server configurations via ccswitch:// URLs. + +use super::utils::decode_base64_param; +use super::DeepLinkImportRequest; +use crate::app_config::{McpApps, McpServer}; +use crate::error::AppError; +use crate::services::McpService; +use crate::store::AppState; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +/// MCP import result +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct McpImportResult { + /// Number of successfully imported MCP servers + pub imported_count: usize, + /// IDs of successfully imported MCP servers + pub imported_ids: Vec, + /// Failed imports with error messages + pub failed: Vec, +} + +/// MCP import error +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct McpImportError { + /// MCP server ID + pub id: String, + /// Error message + pub error: String, +} + +/// Import MCP servers from deep link request +/// +/// This function handles batch import of MCP servers from standard MCP JSON format. +/// If a server already exists, only the apps flags are merged (existing config preserved). +pub fn import_mcp_from_deeplink( + state: &AppState, + request: DeepLinkImportRequest, +) -> Result { + // Verify this is an MCP request + if request.resource != "mcp" { + return Err(AppError::InvalidInput(format!( + "Expected mcp resource, got '{}'", + request.resource + ))); + } + + // Extract and validate apps parameter + let apps_str = request + .apps + .as_ref() + .ok_or_else(|| AppError::InvalidInput("Missing 'apps' parameter for MCP".to_string()))?; + + // Parse apps into McpApps struct + let target_apps = parse_mcp_apps(apps_str)?; + + // Extract config + let config_b64 = request + .config + .as_ref() + .ok_or_else(|| AppError::InvalidInput("Missing 'config' parameter for MCP".to_string()))?; + + // Decode Base64 config + let decoded = decode_base64_param("config", config_b64)?; + + let config_str = String::from_utf8(decoded) + .map_err(|e| AppError::InvalidInput(format!("Invalid UTF-8 in config: {e}")))?; + + // Parse JSON + let config_json: Value = serde_json::from_str(&config_str) + .map_err(|e| AppError::InvalidInput(format!("Invalid JSON in MCP config: {e}")))?; + + // Extract mcpServers object + let mcp_servers = config_json + .get("mcpServers") + .and_then(|v| v.as_object()) + .ok_or_else(|| { + AppError::InvalidInput("MCP config must contain 'mcpServers' object".to_string()) + })?; + + if mcp_servers.is_empty() { + return Err(AppError::InvalidInput( + "No MCP servers found in config".to_string(), + )); + } + + // Get existing servers to check for duplicates + let existing_servers = state.db.get_all_mcp_servers()?; + + // Import each MCP server + let mut imported_ids = Vec::new(); + let mut failed = Vec::new(); + + for (id, server_spec) in mcp_servers.iter() { + // Check if server already exists + let server = if let Some(existing) = existing_servers.get(id) { + // Server exists - merge apps only, keep other fields unchanged + log::info!("MCP server '{id}' already exists, merging apps only"); + + let mut merged_apps = existing.apps.clone(); + // Merge new apps into existing apps + if target_apps.claude { + merged_apps.claude = true; + } + if target_apps.codex { + merged_apps.codex = true; + } + if target_apps.gemini { + merged_apps.gemini = true; + } + + McpServer { + id: existing.id.clone(), + name: existing.name.clone(), + server: existing.server.clone(), // Keep existing server config + apps: merged_apps, // Merged apps + description: existing.description.clone(), + homepage: existing.homepage.clone(), + docs: existing.docs.clone(), + tags: existing.tags.clone(), + } + } else { + // New server - create with provided config + log::info!("Creating new MCP server: {id}"); + McpServer { + id: id.clone(), + name: id.clone(), + server: server_spec.clone(), + apps: target_apps.clone(), + description: None, + homepage: None, + docs: None, + tags: vec!["imported".to_string()], + } + }; + + match McpService::upsert_server(state, server) { + Ok(_) => { + imported_ids.push(id.clone()); + log::info!("Successfully imported/updated MCP server: {id}"); + } + Err(e) => { + failed.push(McpImportError { + id: id.clone(), + error: format!("{e}"), + }); + log::warn!("Failed to import MCP server '{id}': {e}"); + } + } + } + + Ok(McpImportResult { + imported_count: imported_ids.len(), + imported_ids, + failed, + }) +} + +/// Parse apps string into McpApps struct +pub(crate) fn parse_mcp_apps(apps_str: &str) -> Result { + let mut apps = McpApps { + claude: false, + codex: false, + gemini: false, + opencode: false, + hermes: false, + }; + + for app in apps_str.split(',') { + match app.trim() { + "claude" => apps.claude = true, + "codex" => apps.codex = true, + "gemini" => apps.gemini = true, + "opencode" => apps.opencode = true, + "openclaw" => { + // OpenClaw doesn't support MCP, ignore silently + log::debug!("OpenClaw doesn't support MCP, ignoring in apps parameter"); + } + "hermes" => apps.hermes = true, + other => { + return Err(AppError::InvalidInput(format!( + "Invalid app in 'apps': {other}" + ))) + } + } + } + + if apps.is_empty() { + return Err(AppError::InvalidInput( + "At least one app must be specified in 'apps'".to_string(), + )); + } + + Ok(apps) +} diff --git a/src-tauri/src/deeplink/prompt.rs b/src-tauri/src/deeplink/prompt.rs new file mode 100644 index 00000000..719deaed --- /dev/null +++ b/src-tauri/src/deeplink/prompt.rs @@ -0,0 +1,86 @@ +//! Prompt import from deep link +//! +//! Handles importing prompt configurations via ccswitch:// URLs. + +use super::utils::decode_base64_param; +use super::DeepLinkImportRequest; +use crate::error::AppError; +use crate::prompt::Prompt; +use crate::services::PromptService; +use crate::store::AppState; +use crate::AppType; +use std::str::FromStr; + +/// Import a prompt from deep link request +pub fn import_prompt_from_deeplink( + state: &AppState, + request: DeepLinkImportRequest, +) -> Result { + // Verify this is a prompt request + if request.resource != "prompt" { + return Err(AppError::InvalidInput(format!( + "Expected prompt resource, got '{}'", + request.resource + ))); + } + + // Extract required fields + let app_str = request + .app + .as_ref() + .ok_or_else(|| AppError::InvalidInput("Missing 'app' field for prompt".to_string()))?; + + let name = request + .name + .ok_or_else(|| AppError::InvalidInput("Missing 'name' field for prompt".to_string()))?; + + // Parse app type + let app_type = AppType::from_str(app_str) + .map_err(|_| AppError::InvalidInput(format!("Invalid app type: {app_str}")))?; + + // Decode content + let content_b64 = request + .content + .as_ref() + .ok_or_else(|| AppError::InvalidInput("Missing 'content' field for prompt".to_string()))?; + + let content = decode_base64_param("content", content_b64)?; + let content = String::from_utf8(content) + .map_err(|e| AppError::InvalidInput(format!("Invalid UTF-8 in content: {e}")))?; + + // Generate ID + let timestamp = chrono::Utc::now().timestamp_millis(); + let sanitized_name = name + .chars() + .filter(|c| c.is_alphanumeric() || *c == '-' || *c == '_') + .collect::() + .to_lowercase(); + let id = format!("{sanitized_name}-{timestamp}"); + + // Check if we should enable this prompt + let should_enable = request.enabled.unwrap_or(false); + + // Create Prompt (initially disabled) + let prompt = Prompt { + id: id.clone(), + name: name.clone(), + content, + description: request.description, + enabled: false, // Always start as disabled, will be enabled later if needed + created_at: Some(timestamp), + updated_at: Some(timestamp), + }; + + // Save using PromptService + PromptService::upsert_prompt(state, app_type.clone(), &id, prompt)?; + + // If enabled flag is set, enable this prompt (which will disable others) + if should_enable { + PromptService::enable_prompt(state, app_type, &id)?; + log::info!("Successfully imported and enabled prompt '{name}' for {app_str}"); + } else { + log::info!("Successfully imported prompt '{name}' for {app_str} (disabled)"); + } + + Ok(id) +} diff --git a/src-tauri/src/deeplink/skill.rs b/src-tauri/src/deeplink/skill.rs new file mode 100644 index 00000000..d4369eaf --- /dev/null +++ b/src-tauri/src/deeplink/skill.rs @@ -0,0 +1,51 @@ +//! Skill import from deep link +//! +//! Handles importing skill repository configurations via ccswitch:// URLs. + +use super::DeepLinkImportRequest; +use crate::error::AppError; +use crate::services::skill::SkillRepo; +use crate::store::AppState; + +/// Import a skill from deep link request +pub fn import_skill_from_deeplink( + state: &AppState, + request: DeepLinkImportRequest, +) -> Result { + // Verify this is a skill request + if request.resource != "skill" { + return Err(AppError::InvalidInput(format!( + "Expected skill resource, got '{}'", + request.resource + ))); + } + + // Parse repo + let repo_str = request + .repo + .ok_or_else(|| AppError::InvalidInput("Missing 'repo' field for skill".to_string()))?; + + let parts: Vec<&str> = repo_str.split('/').collect(); + if parts.len() != 2 { + return Err(AppError::InvalidInput(format!( + "Invalid repo format: expected 'owner/name', got '{repo_str}'" + ))); + } + let owner = parts[0].to_string(); + let name = parts[1].to_string(); + + // Create SkillRepo + let repo = SkillRepo { + owner: owner.clone(), + name: name.clone(), + branch: request.branch.unwrap_or_else(|| "main".to_string()), + enabled: request.enabled.unwrap_or(true), + }; + + // Save using Database + state.db.save_skill_repo(&repo)?; + + log::info!("Successfully added skill repo '{owner}/{name}'"); + + Ok(format!("{owner}/{name}")) +} From 485cdc7117cf6f5d5a71182c0d707d7c7b85b86b Mon Sep 17 00:00:00 2001 From: LeonardoTan19 <1903048407@qq.com> Date: Mon, 1 Jun 2026 14:51:28 +0800 Subject: [PATCH 2/6] feat(deeplink): add `deeplink` command importing provider/mcp/prompt/skill Wire the ported mcp/prompt/skill handlers into the deeplink parser and re-exports, then expose a top-level `cc-switch deeplink ` command that dispatches by the URL's `resource` type. The deep link's own `app`/`apps` parameters drive the target, so the global `--app` flag is ignored here. Also align imported-prompt timestamps to seconds to match the prompt store (upstream used milliseconds, which rendered far-future dates). --- src-tauri/src/cli/commands/deeplink.rs | 103 ++++++++++++++ src-tauri/src/cli/commands/mod.rs | 1 + src-tauri/src/cli/mod.rs | 3 + src-tauri/src/deeplink/mod.rs | 8 +- src-tauri/src/deeplink/parser.rs | 182 +++++++++++++++++++++++++ src-tauri/src/deeplink/prompt.rs | 9 +- src-tauri/src/lib.rs | 5 +- src-tauri/src/main.rs | 1 + 8 files changed, 307 insertions(+), 5 deletions(-) create mode 100644 src-tauri/src/cli/commands/deeplink.rs diff --git a/src-tauri/src/cli/commands/deeplink.rs b/src-tauri/src/cli/commands/deeplink.rs new file mode 100644 index 00000000..4225e8a9 --- /dev/null +++ b/src-tauri/src/cli/commands/deeplink.rs @@ -0,0 +1,103 @@ +use clap::Args; + +use crate::cli::ui::{info, success}; +use crate::error::AppError; +use crate::store::AppState; + +#[derive(Args, Debug, Clone)] +pub struct DeeplinkCommand { + /// The ccswitch://v1/import?... URL to import + pub url: String, +} + +pub fn execute(cmd: DeeplinkCommand) -> Result<(), AppError> { + // The deep link URL carries its own `app`/`apps` parameters, so the global + // `--app` flag is intentionally ignored here. + let request = crate::parse_deeplink_url(&cmd.url)?; + let state = AppState::try_new()?; + + match request.resource.as_str() { + "provider" => import_provider(&state, request), + "mcp" => import_mcp(&state, request), + "prompt" => import_prompt(&state, request), + "skill" => import_skill(&state, request), + other => Err(AppError::InvalidInput(format!( + "Unsupported resource type: {other}" + ))), + } +} + +fn import_provider( + state: &AppState, + request: crate::DeepLinkImportRequest, +) -> Result<(), AppError> { + let app_label = request.app.clone().unwrap_or_default(); + let name = request.name.clone().unwrap_or_default(); + let switched = request.enabled == Some(true); + + let provider_id = crate::import_provider_from_deeplink(state, request)?; + + println!( + "{}", + success(&format!( + "✓ Imported provider '{name}' (id: {provider_id}) for {app_label}" + )) + ); + if switched { + println!("{}", info(&format!(" Switched to '{provider_id}'"))); + } + Ok(()) +} + +fn import_mcp(state: &AppState, request: crate::DeepLinkImportRequest) -> Result<(), AppError> { + let apps_label = request.apps.clone().unwrap_or_default(); + let result = crate::import_mcp_from_deeplink(state, request)?; + + println!( + "{}", + success(&format!( + "✓ Imported {} MCP server(s) for {apps_label}", + result.imported_count + )) + ); + for id in &result.imported_ids { + println!("{}", info(&format!(" • {id}"))); + } + for failure in &result.failed { + println!( + "{}", + crate::cli::ui::warning(&format!(" ✗ {}: {}", failure.id, failure.error)) + ); + } + Ok(()) +} + +fn import_prompt(state: &AppState, request: crate::DeepLinkImportRequest) -> Result<(), AppError> { + let app_label = request.app.clone().unwrap_or_default(); + let name = request.name.clone().unwrap_or_default(); + let enabled = request.enabled == Some(true); + + let prompt_id = crate::import_prompt_from_deeplink(state, request)?; + + println!( + "{}", + success(&format!( + "✓ Imported prompt '{name}' (id: {prompt_id}) for {app_label}" + )) + ); + if enabled { + println!("{}", info(&format!(" Enabled '{prompt_id}'"))); + } + Ok(()) +} + +fn import_skill(state: &AppState, request: crate::DeepLinkImportRequest) -> Result<(), AppError> { + let repo_id = crate::import_skill_from_deeplink(state, request)?; + + println!("{}", success(&format!("✓ Added skill repo '{repo_id}'"))); + println!( + "{}", + info(" Run `cc-switch skills repos sync` to install skills from it") + ); + Ok(()) +} diff --git a/src-tauri/src/cli/commands/mod.rs b/src-tauri/src/cli/commands/mod.rs index dca920d1..3fa64530 100644 --- a/src-tauri/src/cli/commands/mod.rs +++ b/src-tauri/src/cli/commands/mod.rs @@ -7,6 +7,7 @@ pub(crate) mod config_openclaw; pub mod config_webdav; #[cfg(unix)] pub mod daemon; +pub mod deeplink; pub mod env; pub mod failover; pub mod hermes; diff --git a/src-tauri/src/cli/mod.rs b/src-tauri/src/cli/mod.rs index cee6b853..dba67ab1 100644 --- a/src-tauri/src/cli/mod.rs +++ b/src-tauri/src/cli/mod.rs @@ -104,6 +104,9 @@ pub enum Commands { #[command(subcommand)] Env(commands::env::EnvCommand), + /// Import a resource (provider/mcp/prompt/skill) from a ccswitch:// deep link URL + Deeplink(commands::deeplink::DeeplinkCommand), + /// Update cc-switch binary to latest release Update(commands::update::UpdateCommand), diff --git a/src-tauri/src/deeplink/mod.rs b/src-tauri/src/deeplink/mod.rs index 0b8afffe..47b588ab 100644 --- a/src-tauri/src/deeplink/mod.rs +++ b/src-tauri/src/deeplink/mod.rs @@ -1,17 +1,23 @@ //! Deep link import functionality for CC Switch (CLI edition). //! //! Implements the `ccswitch://v1/import?...` protocol for importing resources. -//! Currently supports importing provider configurations for Claude/Codex/Gemini/OpenCode/OpenClaw. +//! Supports importing providers, MCP servers, prompts, and skill repositories. +mod mcp; mod parser; +mod prompt; mod provider; +mod skill; mod utils; use serde::{Deserialize, Serialize}; use serde_json::Value; +pub use mcp::{import_mcp_from_deeplink, McpImportResult}; pub use parser::parse_deeplink_url; +pub use prompt::import_prompt_from_deeplink; pub use provider::import_provider_from_deeplink; +pub use skill::import_skill_from_deeplink; /// Deep link import request model. /// diff --git a/src-tauri/src/deeplink/parser.rs b/src-tauri/src/deeplink/parser.rs index 79ab6c89..0a9aff62 100644 --- a/src-tauri/src/deeplink/parser.rs +++ b/src-tauri/src/deeplink/parser.rs @@ -48,6 +48,9 @@ pub fn parse_deeplink_url(url_str: &str) -> Result parse_provider_deeplink(¶ms, version, resource), + "prompt" => parse_prompt_deeplink(¶ms, version, resource), + "mcp" => parse_mcp_deeplink(¶ms, version, resource), + "skill" => parse_skill_deeplink(¶ms, version, resource), _ => Err(AppError::InvalidInput(format!( "Unsupported resource type: {resource}" ))), @@ -140,3 +143,182 @@ fn parse_provider_deeplink( openclaw_config: None, }) } + +fn parse_prompt_deeplink( + params: &HashMap, + version: String, + resource: String, +) -> Result { + let app = params + .get("app") + .ok_or_else(|| AppError::InvalidInput("Missing 'app' parameter for prompt".to_string()))? + .clone(); + + if !matches!( + app.as_str(), + "claude" | "codex" | "gemini" | "opencode" | "openclaw" | "hermes" + ) { + return Err(AppError::InvalidInput(format!( + "Invalid app type: must be 'claude', 'codex', 'gemini', 'opencode', 'openclaw', or 'hermes', got '{app}'" + ))); + } + + let name = params + .get("name") + .ok_or_else(|| AppError::InvalidInput("Missing 'name' parameter for prompt".to_string()))? + .clone(); + + let content = params + .get("content") + .ok_or_else(|| { + AppError::InvalidInput("Missing 'content' parameter for prompt".to_string()) + })? + .clone(); + + Ok(DeepLinkImportRequest { + version, + resource, + app: Some(app), + name: Some(name), + enabled: params.get("enabled").and_then(|v| v.parse::().ok()), + content: Some(content), + description: params.get("description").cloned(), + icon: None, + homepage: None, + endpoint: None, + api_key: None, + model: None, + notes: None, + haiku_model: None, + sonnet_model: None, + opus_model: None, + apps: None, + repo: None, + directory: None, + branch: None, + config: None, + config_format: None, + config_url: None, + usage_enabled: None, + usage_script: None, + usage_api_key: None, + usage_base_url: None, + usage_access_token: None, + usage_user_id: None, + usage_auto_interval: None, + openclaw_config: None, + }) +} + +fn parse_mcp_deeplink( + params: &HashMap, + version: String, + resource: String, +) -> Result { + let apps = params + .get("apps") + .ok_or_else(|| AppError::InvalidInput("Missing 'apps' parameter for MCP".to_string()))? + .clone(); + + for app in apps.split(',') { + let trimmed = app.trim(); + if !matches!( + trimmed, + "claude" | "codex" | "gemini" | "opencode" | "openclaw" | "hermes" + ) { + return Err(AppError::InvalidInput(format!( + "Invalid app in 'apps': must be 'claude', 'codex', 'gemini', 'opencode', 'openclaw', or 'hermes', got '{trimmed}'" + ))); + } + } + + let config = params + .get("config") + .ok_or_else(|| AppError::InvalidInput("Missing 'config' parameter for MCP".to_string()))? + .clone(); + + Ok(DeepLinkImportRequest { + version, + resource, + apps: Some(apps), + enabled: params.get("enabled").and_then(|v| v.parse::().ok()), + config: Some(config), + config_format: Some("json".to_string()), + app: None, + name: None, + icon: None, + homepage: None, + endpoint: None, + api_key: None, + model: None, + notes: None, + haiku_model: None, + sonnet_model: None, + opus_model: None, + content: None, + description: None, + repo: None, + directory: None, + branch: None, + config_url: None, + usage_enabled: None, + usage_script: None, + usage_api_key: None, + usage_base_url: None, + usage_access_token: None, + usage_user_id: None, + usage_auto_interval: None, + openclaw_config: None, + }) +} + +fn parse_skill_deeplink( + params: &HashMap, + version: String, + resource: String, +) -> Result { + let repo = params + .get("repo") + .ok_or_else(|| AppError::InvalidInput("Missing 'repo' parameter for skill".to_string()))? + .clone(); + + if !repo.contains('/') || repo.split('/').count() != 2 { + return Err(AppError::InvalidInput(format!( + "Invalid repo format: expected 'owner/name', got '{repo}'" + ))); + } + + Ok(DeepLinkImportRequest { + version, + resource, + repo: Some(repo), + directory: params.get("directory").cloned(), + branch: params.get("branch").cloned(), + icon: None, + app: Some("claude".to_string()), + name: None, + enabled: params.get("enabled").and_then(|v| v.parse::().ok()), + homepage: None, + endpoint: None, + api_key: None, + model: None, + notes: None, + haiku_model: None, + sonnet_model: None, + opus_model: None, + content: None, + description: None, + apps: None, + config: None, + config_format: None, + config_url: None, + usage_enabled: None, + usage_script: None, + usage_api_key: None, + usage_base_url: None, + usage_access_token: None, + usage_user_id: None, + usage_auto_interval: None, + openclaw_config: None, + }) +} diff --git a/src-tauri/src/deeplink/prompt.rs b/src-tauri/src/deeplink/prompt.rs index 719deaed..df8064f0 100644 --- a/src-tauri/src/deeplink/prompt.rs +++ b/src-tauri/src/deeplink/prompt.rs @@ -48,14 +48,17 @@ pub fn import_prompt_from_deeplink( let content = String::from_utf8(content) .map_err(|e| AppError::InvalidInput(format!("Invalid UTF-8 in content: {e}")))?; - // Generate ID - let timestamp = chrono::Utc::now().timestamp_millis(); + // Generate ID (millisecond suffix keeps it unique) + let now = chrono::Utc::now(); let sanitized_name = name .chars() .filter(|c| c.is_alphanumeric() || *c == '-' || *c == '_') .collect::() .to_lowercase(); - let id = format!("{sanitized_name}-{timestamp}"); + let id = format!("{sanitized_name}-{}", now.timestamp_millis()); + + // Timestamps are stored in seconds to match the rest of the prompt store. + let timestamp = now.timestamp(); // Check if we should enable this prompt let should_enable = request.enabled.unwrap_or(false); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index fe65b480..01aab914 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -49,7 +49,10 @@ pub use config::{ get_app_config_dir, get_claude_mcp_path, get_claude_settings_path, read_json_file, }; pub use database::{Database, FailoverQueueItem}; -pub use deeplink::{import_provider_from_deeplink, parse_deeplink_url, DeepLinkImportRequest}; +pub use deeplink::{ + import_mcp_from_deeplink, import_prompt_from_deeplink, import_provider_from_deeplink, + import_skill_from_deeplink, parse_deeplink_url, DeepLinkImportRequest, McpImportResult, +}; pub use error::AppError; pub use import_export::export_config_to_file; pub use mcp::{ diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 7410a85b..4dcf56bf 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -74,6 +74,7 @@ fn run(cli: Cli) -> Result<(), AppError> { #[cfg(unix)] Some(Commands::Daemon(cmd)) => cc_switch_lib::cli::commands::daemon::execute(cmd), Some(Commands::Env(cmd)) => cc_switch_lib::cli::commands::env::execute(cmd, cli.app), + Some(Commands::Deeplink(cmd)) => cc_switch_lib::cli::commands::deeplink::execute(cmd), Some(Commands::Update(cmd)) => cc_switch_lib::cli::commands::update::execute(cmd), Some(Commands::Completions(cmd)) => cc_switch_lib::cli::commands::completions::execute(cmd), Some(Commands::Internal(cmd)) => cc_switch_lib::cli::commands::internal::execute(cmd), From e581a21fe0a4753fcc8818fbe9690fb6f4b8d19f Mon Sep 17 00:00:00 2001 From: LeonardoTan19 <1903048407@qq.com> Date: Mon, 1 Jun 2026 14:55:06 +0800 Subject: [PATCH 3/6] test(deeplink): cover mcp/prompt/skill import paths Add integration tests for the new resource types: MCP server persistence with merged app flags, prompt import with enabled/disabled states, and skill repo registration, plus a malformed-repo rejection case. --- src-tauri/tests/deeplink_import.rs | 126 ++++++++++++++++++++++++++++- 1 file changed, 125 insertions(+), 1 deletion(-) diff --git a/src-tauri/tests/deeplink_import.rs b/src-tauri/tests/deeplink_import.rs index 3904bc91..688cded2 100644 --- a/src-tauri/tests/deeplink_import.rs +++ b/src-tauri/tests/deeplink_import.rs @@ -1,5 +1,8 @@ use base64::prelude::*; -use cc_switch_lib::{import_provider_from_deeplink, parse_deeplink_url, AppType, MultiAppConfig}; +use cc_switch_lib::{ + import_mcp_from_deeplink, import_prompt_from_deeplink, import_provider_from_deeplink, + import_skill_from_deeplink, parse_deeplink_url, AppType, MultiAppConfig, +}; #[path = "support.rs"] mod support; @@ -334,3 +337,124 @@ fn deeplink_import_rejects_non_http_endpoints_from_config() { "expected scheme validation error, got {err:?}" ); } + +#[test] +fn deeplink_import_mcp_server_persists_with_app_flags() { + let _guard = lock_test_mutex(); + reset_test_fs(); + let _home = ensure_test_home(); + + let config_json = r#"{"mcpServers":{"fetch":{"command":"uvx","args":["mcp-server-fetch"]}}}"#; + let config_b64 = BASE64_URL_SAFE_NO_PAD.encode(config_json.as_bytes()); + let url = format!( + "ccswitch://v1/import?resource=mcp&apps=claude,codex&config={config_b64}&enabled=true" + ); + let request = parse_deeplink_url(&url).expect("parse deeplink url"); + + let mut config = MultiAppConfig::default(); + config.ensure_app(&AppType::Claude); + let state = state_from_config(config); + + let result = import_mcp_from_deeplink(&state, request).expect("import mcp from deeplink"); + assert_eq!(result.imported_count, 1); + assert!(result.failed.is_empty(), "no imports should fail"); + assert_eq!(result.imported_ids, vec!["fetch".to_string()]); + + let servers = state.db.get_all_mcp_servers().expect("read mcp servers"); + let server = servers.get("fetch").expect("mcp server persisted"); + assert!(server.apps.claude, "claude flag should be set"); + assert!(server.apps.codex, "codex flag should be set"); + assert!(!server.apps.gemini, "gemini flag should remain unset"); + assert_eq!( + server.server.pointer("/command").and_then(|v| v.as_str()), + Some("uvx") + ); +} + +#[test] +fn deeplink_import_prompt_persists_and_enables() { + let _guard = lock_test_mutex(); + reset_test_fs(); + let _home = ensure_test_home(); + + let content = "You are a helpful assistant."; + let content_b64 = BASE64_URL_SAFE_NO_PAD.encode(content.as_bytes()); + let url = format!( + "ccswitch://v1/import?resource=prompt&app=claude&name=Helper&content={content_b64}&description=desc&enabled=true" + ); + let request = parse_deeplink_url(&url).expect("parse deeplink url"); + + let mut config = MultiAppConfig::default(); + config.ensure_app(&AppType::Claude); + let state = state_from_config(config); + + let prompt_id = import_prompt_from_deeplink(&state, request).expect("import prompt"); + + let prompts = state.db.get_prompts("claude").expect("read prompts"); + let prompt = prompts.get(&prompt_id).expect("prompt persisted"); + assert_eq!(prompt.content, content); + assert_eq!(prompt.name, "Helper"); + assert_eq!(prompt.description.as_deref(), Some("desc")); + assert!(prompt.enabled, "enabled=true should activate the prompt"); +} + +#[test] +fn deeplink_import_prompt_without_enabled_stays_disabled() { + let _guard = lock_test_mutex(); + reset_test_fs(); + let _home = ensure_test_home(); + + let content_b64 = BASE64_URL_SAFE_NO_PAD.encode(b"hello"); + let url = + format!("ccswitch://v1/import?resource=prompt&app=claude&name=Idle&content={content_b64}"); + let request = parse_deeplink_url(&url).expect("parse deeplink url"); + + let mut config = MultiAppConfig::default(); + config.ensure_app(&AppType::Claude); + let state = state_from_config(config); + + let prompt_id = import_prompt_from_deeplink(&state, request).expect("import prompt"); + + let prompts = state.db.get_prompts("claude").expect("read prompts"); + let prompt = prompts.get(&prompt_id).expect("prompt persisted"); + assert!(!prompt.enabled, "prompt should default to disabled"); +} + +#[test] +fn deeplink_import_skill_repo_persists() { + let _guard = lock_test_mutex(); + reset_test_fs(); + let _home = ensure_test_home(); + + let url = "ccswitch://v1/import?resource=skill&repo=octocat/example-skills&branch=dev"; + let request = parse_deeplink_url(url).expect("parse deeplink url"); + + let mut config = MultiAppConfig::default(); + config.ensure_app(&AppType::Claude); + let state = state_from_config(config); + + let repo_id = import_skill_from_deeplink(&state, request).expect("import skill repo"); + assert_eq!(repo_id, "octocat/example-skills"); + + let repos = state.db.get_skill_repos().expect("read skill repos"); + let repo = repos + .iter() + .find(|r| r.owner == "octocat" && r.name == "example-skills") + .expect("skill repo persisted"); + assert_eq!(repo.branch, "dev"); + assert!(repo.enabled, "repo should default to enabled"); +} + +#[test] +fn deeplink_import_skill_rejects_malformed_repo() { + let _guard = lock_test_mutex(); + reset_test_fs(); + ensure_test_home(); + + let err = parse_deeplink_url("ccswitch://v1/import?resource=skill&repo=not-a-repo") + .expect_err("malformed repo should be rejected"); + assert!( + err.to_string().contains("Invalid repo format"), + "expected repo format error, got {err:?}" + ); +} From e62f7300bc07739f7fbbc6ea9ab0cb06677214ef Mon Sep 17 00:00:00 2001 From: LeonardoTan19 <1903048407@qq.com> Date: Mon, 1 Jun 2026 16:27:19 +0800 Subject: [PATCH 4/6] fix(deeplink): align parser with upstream and fix MCP app merge - Align parser.rs with upstream cc-switch: add hermes to provider app validation, use local variable extraction style consistently - Fix MCP import merge logic missing opencode/hermes app flags for existing servers - Remove misleading hint message that referenced non-existent command --- src-tauri/src/cli/commands/deeplink.rs | 4 - src-tauri/src/deeplink/mcp.rs | 6 ++ src-tauri/src/deeplink/parser.rs | 135 +++++++++++++++++-------- 3 files changed, 97 insertions(+), 48 deletions(-) diff --git a/src-tauri/src/cli/commands/deeplink.rs b/src-tauri/src/cli/commands/deeplink.rs index 4225e8a9..5692bea2 100644 --- a/src-tauri/src/cli/commands/deeplink.rs +++ b/src-tauri/src/cli/commands/deeplink.rs @@ -95,9 +95,5 @@ fn import_skill(state: &AppState, request: crate::DeepLinkImportRequest) -> Resu let repo_id = crate::import_skill_from_deeplink(state, request)?; println!("{}", success(&format!("✓ Added skill repo '{repo_id}'"))); - println!( - "{}", - info(" Run `cc-switch skills repos sync` to install skills from it") - ); Ok(()) } diff --git a/src-tauri/src/deeplink/mcp.rs b/src-tauri/src/deeplink/mcp.rs index 822cd839..27aae7cf 100644 --- a/src-tauri/src/deeplink/mcp.rs +++ b/src-tauri/src/deeplink/mcp.rs @@ -112,6 +112,12 @@ pub fn import_mcp_from_deeplink( if target_apps.gemini { merged_apps.gemini = true; } + if target_apps.opencode { + merged_apps.opencode = true; + } + if target_apps.hermes { + merged_apps.hermes = true; + } McpServer { id: existing.id.clone(), diff --git a/src-tauri/src/deeplink/parser.rs b/src-tauri/src/deeplink/parser.rs index 0a9aff62..eb1cc89c 100644 --- a/src-tauri/src/deeplink/parser.rs +++ b/src-tauri/src/deeplink/parser.rs @@ -1,6 +1,6 @@ -//! Deep link URL parser. +//! Deep link URL parser //! -//! Parses `ccswitch://` URLs into `DeepLinkImportRequest` structures. +//! Parses ccswitch:// URLs into DeepLinkImportRequest structures. use super::utils::validate_url; use super::DeepLinkImportRequest; @@ -8,14 +8,16 @@ use crate::error::AppError; use std::collections::HashMap; use url::Url; -/// Parse a `ccswitch://` URL into a `DeepLinkImportRequest`. +/// Parse a ccswitch:// URL into a DeepLinkImportRequest /// /// Expected format: -/// `ccswitch://v1/import?resource=provider&...` +/// ccswitch://v1/import?resource={type}&... pub fn parse_deeplink_url(url_str: &str) -> Result { + // Parse URL let url = Url::parse(url_str) .map_err(|e| AppError::InvalidInput(format!("Invalid deep link URL: {e}")))?; + // Validate scheme let scheme = url.scheme(); if scheme != "ccswitch" { return Err(AppError::InvalidInput(format!( @@ -23,16 +25,20 @@ pub fn parse_deeplink_url(url_str: &str) -> Result Result = url.query_pairs().into_owned().collect(); + + // Extract and validate resource type let resource = params .get("resource") .ok_or_else(|| AppError::InvalidInput("Missing 'resource' parameter".to_string()))? .clone(); + // Dispatch to appropriate parser based on resource type match resource.as_str() { "provider" => parse_provider_deeplink(¶ms, version, resource), "prompt" => parse_prompt_deeplink(¶ms, version, resource), @@ -57,6 +67,7 @@ pub fn parse_deeplink_url(url_str: &str) -> Result, version: String, @@ -67,14 +78,13 @@ fn parse_provider_deeplink( .ok_or_else(|| AppError::InvalidInput("Missing 'app' parameter".to_string()))? .clone(); - if app != "claude" - && app != "codex" - && app != "gemini" - && app != "opencode" - && app != "openclaw" - { + // Validate app type + if !matches!( + app.as_str(), + "claude" | "codex" | "gemini" | "opencode" | "openclaw" | "hermes" + ) { return Err(AppError::InvalidInput(format!( - "Invalid app type: must be 'claude', 'codex', 'gemini', 'opencode', or 'openclaw', got '{app}'" + "Invalid app type: must be 'claude', 'codex', 'gemini', 'opencode', 'openclaw', or 'hermes', got '{app}'" ))); } @@ -83,16 +93,18 @@ fn parse_provider_deeplink( .ok_or_else(|| AppError::InvalidInput("Missing 'name' parameter".to_string()))? .clone(); + // Make these optional for config file auto-fill (v3.8+) let homepage = params.get("homepage").cloned(); let endpoint = params.get("endpoint").cloned(); let api_key = params.get("apiKey").cloned(); + // Validate URLs only if provided if let Some(ref hp) = homepage { if !hp.is_empty() { validate_url(hp, "homepage")?; } } - + // Validate each endpoint (supports comma-separated multiple URLs) if let Some(ref ep) = endpoint { for (i, url) in ep.split(',').enumerate() { let trimmed = url.trim(); @@ -102,48 +114,70 @@ fn parse_provider_deeplink( } } + // Extract optional fields + let model = params.get("model").cloned(); + let notes = params.get("notes").cloned(); + let haiku_model = params.get("haikuModel").cloned(); + let sonnet_model = params.get("sonnetModel").cloned(); + let opus_model = params.get("opusModel").cloned(); + let icon = params + .get("icon") + .map(|v| v.trim().to_lowercase()) + .filter(|v| !v.is_empty()); + let config = params.get("config").cloned(); + let config_format = params.get("configFormat").cloned(); + let config_url = params.get("configUrl").cloned(); + let enabled = params.get("enabled").and_then(|v| v.parse::().ok()); + + // Extract usage script fields (v3.9+) + let usage_enabled = params + .get("usageEnabled") + .and_then(|v| v.parse::().ok()); + let usage_script = params.get("usageScript").cloned(); + let usage_api_key = params.get("usageApiKey").cloned(); + let usage_base_url = params.get("usageBaseUrl").cloned(); + let usage_access_token = params.get("usageAccessToken").cloned(); + let usage_user_id = params.get("usageUserId").cloned(); + let usage_auto_interval = params + .get("usageAutoInterval") + .and_then(|v| v.parse::().ok()); + Ok(DeepLinkImportRequest { version, resource, app: Some(app), name: Some(name), - enabled: params.get("enabled").and_then(|v| v.parse::().ok()), + enabled, homepage, endpoint, api_key, - icon: params - .get("icon") - .map(|v| v.trim().to_lowercase()) - .filter(|v| !v.is_empty()), - model: params.get("model").cloned(), - notes: params.get("notes").cloned(), - haiku_model: params.get("haikuModel").cloned(), - sonnet_model: params.get("sonnetModel").cloned(), - opus_model: params.get("opusModel").cloned(), + icon, + model, + notes, + haiku_model, + sonnet_model, + opus_model, content: None, description: None, apps: None, repo: None, directory: None, branch: None, - config: params.get("config").cloned(), - config_format: params.get("configFormat").cloned(), - config_url: params.get("configUrl").cloned(), - usage_enabled: params - .get("usageEnabled") - .and_then(|v| v.parse::().ok()), - usage_script: params.get("usageScript").cloned(), - usage_api_key: params.get("usageApiKey").cloned(), - usage_base_url: params.get("usageBaseUrl").cloned(), - usage_access_token: params.get("usageAccessToken").cloned(), - usage_user_id: params.get("usageUserId").cloned(), - usage_auto_interval: params - .get("usageAutoInterval") - .and_then(|v| v.parse::().ok()), + config, + config_format, + config_url, + usage_enabled, + usage_script, + usage_api_key, + usage_base_url, + usage_access_token, + usage_user_id, + usage_auto_interval, openclaw_config: None, }) } +/// Parse prompt deep link parameters fn parse_prompt_deeplink( params: &HashMap, version: String, @@ -154,6 +188,7 @@ fn parse_prompt_deeplink( .ok_or_else(|| AppError::InvalidInput("Missing 'app' parameter for prompt".to_string()))? .clone(); + // Validate app type if !matches!( app.as_str(), "claude" | "codex" | "gemini" | "opencode" | "openclaw" | "hermes" @@ -175,14 +210,17 @@ fn parse_prompt_deeplink( })? .clone(); + let description = params.get("description").cloned(); + let enabled = params.get("enabled").and_then(|v| v.parse::().ok()); + Ok(DeepLinkImportRequest { version, resource, app: Some(app), name: Some(name), - enabled: params.get("enabled").and_then(|v| v.parse::().ok()), + enabled, content: Some(content), - description: params.get("description").cloned(), + description, icon: None, homepage: None, endpoint: None, @@ -210,6 +248,7 @@ fn parse_prompt_deeplink( }) } +/// Parse MCP deep link parameters fn parse_mcp_deeplink( params: &HashMap, version: String, @@ -220,6 +259,7 @@ fn parse_mcp_deeplink( .ok_or_else(|| AppError::InvalidInput("Missing 'apps' parameter for MCP".to_string()))? .clone(); + // Validate apps format for app in apps.split(',') { let trimmed = app.trim(); if !matches!( @@ -237,13 +277,15 @@ fn parse_mcp_deeplink( .ok_or_else(|| AppError::InvalidInput("Missing 'config' parameter for MCP".to_string()))? .clone(); + let enabled = params.get("enabled").and_then(|v| v.parse::().ok()); + Ok(DeepLinkImportRequest { version, resource, apps: Some(apps), - enabled: params.get("enabled").and_then(|v| v.parse::().ok()), + enabled, config: Some(config), - config_format: Some("json".to_string()), + config_format: Some("json".to_string()), // MCP config is always JSON app: None, name: None, icon: None, @@ -272,6 +314,7 @@ fn parse_mcp_deeplink( }) } +/// Parse skill deep link parameters fn parse_skill_deeplink( params: &HashMap, version: String, @@ -282,22 +325,26 @@ fn parse_skill_deeplink( .ok_or_else(|| AppError::InvalidInput("Missing 'repo' parameter for skill".to_string()))? .clone(); + // Validate repo format (should be "owner/name") if !repo.contains('/') || repo.split('/').count() != 2 { return Err(AppError::InvalidInput(format!( "Invalid repo format: expected 'owner/name', got '{repo}'" ))); } + let directory = params.get("directory").cloned(); + let branch = params.get("branch").cloned(); + Ok(DeepLinkImportRequest { version, resource, repo: Some(repo), - directory: params.get("directory").cloned(), - branch: params.get("branch").cloned(), + directory, + branch, icon: None, - app: Some("claude".to_string()), + app: Some("claude".to_string()), // Skills are Claude-only name: None, - enabled: params.get("enabled").and_then(|v| v.parse::().ok()), + enabled: None, homepage: None, endpoint: None, api_key: None, From 213d58ab48421ab22592f8b5100f52a9b06f3aa1 Mon Sep 17 00:00:00 2001 From: LeonardoTan19 <1903048407@qq.com> Date: Mon, 8 Jun 2026 21:16:26 +0800 Subject: [PATCH 5/6] =?UTF-8?q?test(deeplink):=20harden=20integration=20te?= =?UTF-8?q?sts=20=E2=80=94=20merge=20redundant=20tests,=20add=20dispatch/M?= =?UTF-8?q?CP/parser/cross-app=20coverage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Merge three OpenClaw config rejection tests into one parameterized test - Add command dispatch test covering all four resource types via execute() - Add MCP apps=openclaw-only late-error behavior test - Add cross-app prompt import test for codex - Add parser error tests: unknown resource, missing params, invalid MCP app - 17 tests total, zero semantic changes --- src-tauri/tests/deeplink_import.rs | 284 +++++++++++++++++++++-------- 1 file changed, 205 insertions(+), 79 deletions(-) diff --git a/src-tauri/tests/deeplink_import.rs b/src-tauri/tests/deeplink_import.rs index 688cded2..8e6c4a3b 100644 --- a/src-tauri/tests/deeplink_import.rs +++ b/src-tauri/tests/deeplink_import.rs @@ -227,87 +227,52 @@ fn deeplink_import_openclaw_provider_preserves_canonical_inline_config() { } #[test] -fn deeplink_import_openclaw_provider_rejects_legacy_alias_config_shapes() { +fn deeplink_import_openclaw_provider_rejects_invalid_inline_config() { let _guard = lock_test_mutex(); - reset_test_fs(); - let _home = ensure_test_home(); - - let config_json = r#"{"api_key":"sk-legacy-openclaw","base_url":"https://legacy.openclaw.example/v1","options":{"apiKey":"sk-opencode-alias","baseURL":"https://opencode-shape.example/v1"},"models":[{"id":"config-model","context_window":128000}]}"#; - let config_b64 = BASE64_URL_SAFE_NO_PAD.encode(config_json.as_bytes()); - - let url = format!( - "ccswitch://v1/import?resource=provider&app=openclaw&name=Legacy%20OpenClaw&config={config_b64}&configFormat=json" - ); - let request = parse_deeplink_url(&url).expect("parse deeplink url"); - let mut config = MultiAppConfig::default(); - config.ensure_app(&AppType::OpenClaw); - - let state = state_from_config(config); - - let err = import_provider_from_deeplink(&state, request) - .expect_err("legacy OpenClaw alias shapes should be rejected"); - assert!( - err.to_string().contains("api_key") - && err.to_string().contains("base_url") - && err.to_string().contains("options"), - "expected explicit legacy-alias rejection, got {err:?}" - ); -} - -#[test] -fn deeplink_import_openclaw_provider_rejects_legacy_context_window_alias() { - let _guard = lock_test_mutex(); - reset_test_fs(); - let _home = ensure_test_home(); - - let config_json = r#"{"apiKey":"sk-config-openclaw","baseUrl":"https://config.openclaw.example/v1","models":[{"id":"config-model","context_window":128000}]}"#; - let config_b64 = BASE64_URL_SAFE_NO_PAD.encode(config_json.as_bytes()); - - let url = format!( - "ccswitch://v1/import?resource=provider&app=openclaw&name=Legacy%20Context%20Window&config={config_b64}&configFormat=json" - ); - let request = parse_deeplink_url(&url).expect("parse deeplink url"); - - let mut config = MultiAppConfig::default(); - config.ensure_app(&AppType::OpenClaw); - - let state = state_from_config(config); - - let err = import_provider_from_deeplink(&state, request) - .expect_err("legacy OpenClaw model aliases should be rejected"); - assert!( - err.to_string().contains("context_window"), - "expected explicit legacy model-alias rejection, got {err:?}" - ); -} - -#[test] -fn deeplink_import_openclaw_provider_rejects_invalid_canonical_config_shape() { - let _guard = lock_test_mutex(); - reset_test_fs(); - let _home = ensure_test_home(); - - let config_json = r#"{"apiKey":"sk-config-openclaw","baseUrl":"https://config.openclaw.example/v1","models":{"id":"config-model"}}"#; - let config_b64 = BASE64_URL_SAFE_NO_PAD.encode(config_json.as_bytes()); - - let url = format!( - "ccswitch://v1/import?resource=provider&app=openclaw&name=Invalid%20Canonical%20Shape&config={config_b64}&configFormat=json" - ); - let request = parse_deeplink_url(&url).expect("parse deeplink url"); - - let mut config = MultiAppConfig::default(); - config.ensure_app(&AppType::OpenClaw); - - let state = state_from_config(config); - - let err = import_provider_from_deeplink(&state, request) - .expect_err("invalid canonical OpenClaw config shape should be rejected"); - assert!( - err.to_string().contains("invalid OpenClaw provider schema") - || err.to_string().contains("models"), - "expected canonical-schema validation error, got {err:?}" - ); + let cases: &[(&str, &str, &str)] = &[ + // (case label, invalid config JSON, expected error fragment) + ( + "legacy alias fields (api_key, base_url, options)", + r#"{"api_key":"sk-legacy","base_url":"https://legacy.example/v1","options":{"apiKey":"sk-alias","baseURL":"https://alias.example/v1"},"models":[{"id":"m"}]}"#, + "api_key", + ), + ( + "legacy context_window alias on model", + r#"{"apiKey":"sk","baseUrl":"https://example.com/v1","models":[{"id":"m","context_window":128000}]}"#, + "context_window", + ), + ( + "models field is object instead of array", + r#"{"apiKey":"sk","baseUrl":"https://example.com/v1","models":{"id":"m"}}"#, + "invalid OpenClaw provider schema", + ), + ]; + + for (label, config_json, expected_err) in cases { + reset_test_fs(); + let _home = ensure_test_home(); + + let config_b64 = BASE64_URL_SAFE_NO_PAD.encode(config_json.as_bytes()); + let url = format!( + "ccswitch://v1/import?resource=provider&app=openclaw&name=BadConfig&config={config_b64}&configFormat=json" + ); + let request = + parse_deeplink_url(&url).unwrap_or_else(|e| panic!("[{label}] parse failed: {e}")); + + let mut config = MultiAppConfig::default(); + config.ensure_app(&AppType::OpenClaw); + let state = state_from_config(config); + + let err = match import_provider_from_deeplink(&state, request) { + Err(e) => e, + Ok(_) => panic!("[{label}] should have rejected config but succeeded"), + }; + assert!( + err.to_string().contains(expected_err), + "[{label}] expected error containing '{expected_err}', got: {err}" + ); + } } #[test] @@ -371,6 +336,32 @@ fn deeplink_import_mcp_server_persists_with_app_flags() { ); } +#[test] +fn deeplink_import_mcp_apps_openclaw_only_fails_with_apps_required() { + let _guard = lock_test_mutex(); + reset_test_fs(); + let _home = ensure_test_home(); + + let config_json = r#"{"mcpServers":{"test-server":{"command":"echo","args":["hi"]}}}"#; + let config_b64 = BASE64_URL_SAFE_NO_PAD.encode(config_json.as_bytes()); + let url = format!("ccswitch://v1/import?resource=mcp&apps=openclaw&config={config_b64}"); + let request = parse_deeplink_url(&url).expect("openclaw passes parser-level app validation"); + + let mut config = MultiAppConfig::default(); + config.ensure_app(&AppType::Claude); + let state = state_from_config(config); + + let err = match import_mcp_from_deeplink(&state, request) { + Err(e) => e, + Ok(_) => panic!("openclaw-only apps should have failed"), + }; + assert!( + err.to_string() + .contains("At least one app must be specified"), + "expected late error about no apps, got: {err}" + ); +} + #[test] fn deeplink_import_prompt_persists_and_enables() { let _guard = lock_test_mutex(); @@ -420,6 +411,32 @@ fn deeplink_import_prompt_without_enabled_stays_disabled() { assert!(!prompt.enabled, "prompt should default to disabled"); } +#[test] +fn deeplink_import_prompt_for_non_claude_app() { + let _guard = lock_test_mutex(); + reset_test_fs(); + let _home = ensure_test_home(); + + let content_b64 = BASE64_URL_SAFE_NO_PAD.encode(b"codex prompt content"); + let url = format!( + "ccswitch://v1/import?resource=prompt&app=codex&name=CodexPrompt&content={content_b64}&description=codex-desc" + ); + let request = parse_deeplink_url(&url).expect("parse deeplink url"); + + let mut config = MultiAppConfig::default(); + config.ensure_app(&AppType::Codex); + let state = state_from_config(config); + + let prompt_id = import_prompt_from_deeplink(&state, request).expect("import prompt for codex"); + + let prompts = state.db.get_prompts("codex").expect("read codex prompts"); + let prompt = prompts.get(&prompt_id).expect("prompt persisted for codex"); + assert_eq!(prompt.name, "CodexPrompt"); + assert_eq!(prompt.content, "codex prompt content"); + assert_eq!(prompt.description.as_deref(), Some("codex-desc")); + assert!(!prompt.enabled, "should default to disabled"); +} + #[test] fn deeplink_import_skill_repo_persists() { let _guard = lock_test_mutex(); @@ -458,3 +475,112 @@ fn deeplink_import_skill_rejects_malformed_repo() { "expected repo format error, got {err:?}" ); } + +#[test] +fn deeplink_command_execute_dispatches_by_resource_type() { + let _guard = lock_test_mutex(); + + let mcp_content = r#"{"mcpServers":{"dispatch-mcp":{"command":"echo","args":["hello"]}}}"#; + let mcp_b64 = BASE64_URL_SAFE_NO_PAD.encode(mcp_content.as_bytes()); + let prompt_b64 = BASE64_URL_SAFE_NO_PAD.encode(b"dispatch prompt content"); + + let resource_urls: &[(&str, &str)] = &[ + ( + "provider", + "ccswitch://v1/import?resource=provider&app=claude&name=DispatchProvider&homepage=https%3A%2F%2Fexample.com&endpoint=https%3A%2F%2Fapi.example.com%2Fv1&apiKey=sk-dispatch", + ), + ( + "mcp", + &format!("ccswitch://v1/import?resource=mcp&apps=claude,codex&config={mcp_b64}"), + ), + ( + "prompt", + &format!("ccswitch://v1/import?resource=prompt&app=claude&name=DispatchPrompt&content={prompt_b64}"), + ), + ( + "skill", + "ccswitch://v1/import?resource=skill&repo=dispatch-org/dispatch-skills", + ), + ]; + + for (resource, url) in resource_urls { + reset_test_fs(); + let _home = ensure_test_home(); + + let cmd = cc_switch_lib::cli::commands::deeplink::DeeplinkCommand { + url: url.to_string(), + }; + cc_switch_lib::cli::commands::deeplink::execute(cmd) + .unwrap_or_else(|e| panic!("[{resource}] execute() should succeed, got: {e}")); + } +} + +#[test] +fn deeplink_parse_rejects_unknown_resource_type() { + let _guard = lock_test_mutex(); + reset_test_fs(); + ensure_test_home(); + + let err = parse_deeplink_url("ccswitch://v1/import?resource=unknown&app=claude&name=test") + .expect_err("unknown resource type should be rejected at parser level"); + assert!( + err.to_string().contains("Unsupported resource type"), + "expected 'Unsupported resource type', got: {err}" + ); +} + +#[test] +fn deeplink_parse_rejects_missing_required_params() { + let _guard = lock_test_mutex(); + + let cases: &[(&str, &str, &str)] = &[ + // (case label, URL missing a required param, expected error fragment) + ( + "mcp without config", + "ccswitch://v1/import?resource=mcp&apps=claude", + "config", + ), + ( + "prompt without content", + "ccswitch://v1/import?resource=prompt&app=claude&name=NoContent", + "content", + ), + ( + "provider without app", + "ccswitch://v1/import?resource=provider&name=NoApp", + "app", + ), + ]; + + for (label, url, expected_fragment) in cases { + reset_test_fs(); + ensure_test_home(); + + let err = match parse_deeplink_url(url) { + Err(e) => e, + Ok(_) => panic!("[{label}] parser should have rejected URL"), + }; + assert!( + err.to_string() + .to_lowercase() + .contains(&expected_fragment.to_lowercase()), + "[{label}] expected error mentioning '{expected_fragment}', got: {err}" + ); + } +} + +#[test] +fn deeplink_parse_rejects_mcp_invalid_app_in_apps_list() { + let _guard = lock_test_mutex(); + reset_test_fs(); + ensure_test_home(); + + let err = parse_deeplink_url( + "ccswitch://v1/import?resource=mcp&apps=claude,notanapp&config=dGVzdA==", + ) + .expect_err("invalid app in apps list should be rejected at parser level"); + assert!( + err.to_string().to_lowercase().contains("invalid app"), + "expected 'invalid app' error, got: {err}" + ); +} From 904cbca28931c1d17d2fd5b33714ddbc590d2266 Mon Sep 17 00:00:00 2001 From: LeonardoTan19 <1903048407@qq.com> Date: Thu, 11 Jun 2026 01:09:23 +0800 Subject: [PATCH 6/6] feat(deeplink): reject --app flag in favor of URL-encoded target apps --- src-tauri/src/cli/commands/deeplink.rs | 60 ++++++++++++++++++++++++-- src-tauri/src/main.rs | 4 +- 2 files changed, 60 insertions(+), 4 deletions(-) diff --git a/src-tauri/src/cli/commands/deeplink.rs b/src-tauri/src/cli/commands/deeplink.rs index 5692bea2..0d2dbeca 100644 --- a/src-tauri/src/cli/commands/deeplink.rs +++ b/src-tauri/src/cli/commands/deeplink.rs @@ -1,5 +1,6 @@ use clap::Args; +use crate::app_config::AppType; use crate::cli::ui::{info, success}; use crate::error::AppError; use crate::store::AppState; @@ -10,9 +11,14 @@ pub struct DeeplinkCommand { pub url: String, } -pub fn execute(cmd: DeeplinkCommand) -> Result<(), AppError> { - // The deep link URL carries its own `app`/`apps` parameters, so the global - // `--app` flag is intentionally ignored here. +pub fn execute(cmd: DeeplinkCommand, app: Option) -> Result<(), AppError> { + if app.is_some() { + return Err(AppError::InvalidInput( + "`--app` cannot be used with `deeplink`; target app(s) must be encoded in the URL via `app` or `apps`." + .to_string(), + )); + } + let request = crate::parse_deeplink_url(&cmd.url)?; let state = AppState::try_new()?; @@ -97,3 +103,51 @@ fn import_skill(state: &AppState, request: crate::DeepLinkImportRequest) -> Resu println!("{}", success(&format!("✓ Added skill repo '{repo_id}'"))); Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + fn command() -> DeeplinkCommand { + DeeplinkCommand { + url: "ccswitch://v1/import?resource=provider&app=claude&name=Demo".to_string(), + } + } + + #[test] + fn rejects_global_app_flag() { + let err = execute(command(), Some(AppType::Codex)) + .expect_err("`--app` should be rejected for deeplink"); + + match err { + AppError::InvalidInput(message) => { + assert!( + message.contains("`--app` cannot be used with `deeplink`"), + "unexpected message: {message}" + ); + assert!( + message.contains("`app` or `apps`"), + "message should point users to the URL parameters: {message}" + ); + } + other => panic!("expected InvalidInput error, got {other:?}"), + } + } + + #[test] + fn app_flag_is_rejected_before_parsing_url() { + // An invalid URL would normally fail during parsing; the `--app` guard + // must short-circuit first so the error always points at the flag. + let cmd = DeeplinkCommand { + url: "not-a-valid-deeplink".to_string(), + }; + + let err = execute(cmd, Some(AppType::Claude)) + .expect_err("`--app` should be rejected before URL parsing"); + + assert!( + matches!(&err, AppError::InvalidInput(message) if message.contains("`--app` cannot be used with `deeplink`")), + "expected the `--app` rejection, got {err:?}" + ); + } +} diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 4dcf56bf..9b822752 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -74,7 +74,9 @@ fn run(cli: Cli) -> Result<(), AppError> { #[cfg(unix)] Some(Commands::Daemon(cmd)) => cc_switch_lib::cli::commands::daemon::execute(cmd), Some(Commands::Env(cmd)) => cc_switch_lib::cli::commands::env::execute(cmd, cli.app), - Some(Commands::Deeplink(cmd)) => cc_switch_lib::cli::commands::deeplink::execute(cmd), + Some(Commands::Deeplink(cmd)) => { + cc_switch_lib::cli::commands::deeplink::execute(cmd, cli.app) + } Some(Commands::Update(cmd)) => cc_switch_lib::cli::commands::update::execute(cmd), Some(Commands::Completions(cmd)) => cc_switch_lib::cli::commands::completions::execute(cmd), Some(Commands::Internal(cmd)) => cc_switch_lib::cli::commands::internal::execute(cmd),