diff --git a/src-tauri/src/cli/commands/deeplink.rs b/src-tauri/src/cli/commands/deeplink.rs new file mode 100644 index 00000000..0d2dbeca --- /dev/null +++ b/src-tauri/src/cli/commands/deeplink.rs @@ -0,0 +1,153 @@ +use clap::Args; + +use crate::app_config::AppType; +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, 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()?; + + 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}'"))); + 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/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/mcp.rs b/src-tauri/src/deeplink/mcp.rs new file mode 100644 index 00000000..27aae7cf --- /dev/null +++ b/src-tauri/src/deeplink/mcp.rs @@ -0,0 +1,205 @@ +//! 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; + } + if target_apps.opencode { + merged_apps.opencode = true; + } + if target_apps.hermes { + merged_apps.hermes = 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/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..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), + "mcp" => parse_mcp_deeplink(¶ms, version, resource), + "skill" => parse_skill_deeplink(¶ms, version, resource), _ => Err(AppError::InvalidInput(format!( "Unsupported resource type: {resource}" ))), } } +/// Parse provider deep link parameters fn parse_provider_deeplink( params: &HashMap, version: String, @@ -64,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}'" ))); } @@ -80,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(); @@ -99,44 +114,258 @@ 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, + resource: String, +) -> Result { + let app = params + .get("app") + .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" + ) { + 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(); + + 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, + content: Some(content), + description, + 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, + }) +} + +/// Parse MCP deep link parameters +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(); + + // Validate apps format + 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(); + + let enabled = params.get("enabled").and_then(|v| v.parse::().ok()); + + Ok(DeepLinkImportRequest { + version, + resource, + apps: Some(apps), + enabled, + config: Some(config), + config_format: Some("json".to_string()), // MCP config is always JSON + 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, + }) +} + +/// Parse skill deep link parameters +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(); + + // 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, + branch, + icon: None, + app: Some("claude".to_string()), // Skills are Claude-only + name: None, + enabled: 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, + 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 new file mode 100644 index 00000000..df8064f0 --- /dev/null +++ b/src-tauri/src/deeplink/prompt.rs @@ -0,0 +1,89 @@ +//! 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 (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}-{}", 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); + + // 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}")) +} 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..9b822752 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -74,6 +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, 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), diff --git a/src-tauri/tests/deeplink_import.rs b/src-tauri/tests/deeplink_import.rs index 3904bc91..8e6c4a3b 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; @@ -224,113 +227,360 @@ 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(); + + 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] +fn deeplink_import_rejects_non_http_endpoints_from_config() { let _guard = lock_test_mutex(); reset_test_fs(); - let _home = ensure_test_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_json = + r#"{"env":{"ANTHROPIC_AUTH_TOKEN":"sk-test","ANTHROPIC_BASE_URL":"ftp://example.com/v1"}}"#; 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" + "ccswitch://v1/import?resource=provider&app=claude&name=BadEndpoint&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); + config.ensure_app(&AppType::Claude); let state = state_from_config(config); let err = import_provider_from_deeplink(&state, request) - .expect_err("legacy OpenClaw alias shapes should be rejected"); + .expect_err("non-http endpoints 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:?}" + err.to_string().contains("Invalid URL scheme"), + "expected scheme validation error, got {err:?}" ); } #[test] -fn deeplink_import_openclaw_provider_rejects_legacy_context_window_alias() { +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#"{"apiKey":"sk-config-openclaw","baseUrl":"https://config.openclaw.example/v1","models":[{"id":"config-model","context_window":128000}]}"#; + 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=provider&app=openclaw&name=Legacy%20Context%20Window&config={config_b64}&configFormat=json" + "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::OpenClaw); - + config.ensure_app(&AppType::Claude); 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:?}" + 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_openclaw_provider_rejects_invalid_canonical_config_shape() { +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#"{"apiKey":"sk-config-openclaw","baseUrl":"https://config.openclaw.example/v1","models":{"id":"config-model"}}"#; + 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(); + 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=provider&app=openclaw&name=Invalid%20Canonical%20Shape&config={config_b64}&configFormat=json" + "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::OpenClaw); - + config.ensure_app(&AppType::Claude); 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 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_rejects_non_http_endpoints_from_config() { +fn deeplink_import_prompt_without_enabled_stays_disabled() { let _guard = lock_test_mutex(); reset_test_fs(); - ensure_test_home(); + let _home = ensure_test_home(); - let config_json = - r#"{"env":{"ANTHROPIC_AUTH_TOKEN":"sk-test","ANTHROPIC_BASE_URL":"ftp://example.com/v1"}}"#; - let config_b64 = BASE64_URL_SAFE_NO_PAD.encode(config_json.as_bytes()); + 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_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=provider&app=claude&name=BadEndpoint&config={config_b64}&configFormat=json" + "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::Claude); + 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(); + 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 err = import_provider_from_deeplink(&state, request) - .expect_err("non-http endpoints should be rejected"); + 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 URL scheme"), - "expected scheme validation error, got {err:?}" + err.to_string().contains("Invalid repo format"), + "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}" ); }