Skip to content
153 changes: 153 additions & 0 deletions src-tauri/src/cli/commands/deeplink.rs
Original file line number Diff line number Diff line change
@@ -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<AppType>) -> 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:?}"
);
}
}
1 change: 1 addition & 0 deletions src-tauri/src/cli/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
3 changes: 3 additions & 0 deletions src-tauri/src/cli/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),

Expand Down
205 changes: 205 additions & 0 deletions src-tauri/src/deeplink/mcp.rs
Original file line number Diff line number Diff line change
@@ -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<String>,
/// Failed imports with error messages
pub failed: Vec<McpImportError>,
}

/// 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<McpImportResult, AppError> {
// 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<McpApps, AppError> {
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)
}
Loading