feat: enhance skills downloading and proxy configuration

This commit is contained in:
Gerard-Devlin 2026-06-13 15:34:17 +08:00
parent bcbbd449dc
commit 69b5e960e8
4 changed files with 868 additions and 239 deletions

View file

@ -2172,9 +2172,11 @@ dependencies = [
"percent-encoding", "percent-encoding",
"pin-project-lite", "pin-project-lite",
"socket2 0.5.10", "socket2 0.5.10",
"system-configuration 0.7.0",
"tokio", "tokio",
"tower-service", "tower-service",
"tracing", "tracing",
"windows-registry",
] ]
[[package]] [[package]]
@ -4054,7 +4056,7 @@ dependencies = [
"serde_json", "serde_json",
"serde_urlencoded", "serde_urlencoded",
"sync_wrapper 0.1.2", "sync_wrapper 0.1.2",
"system-configuration", "system-configuration 0.5.1",
"tokio", "tokio",
"tokio-native-tls", "tokio-native-tls",
"tower-service", "tower-service",
@ -4968,7 +4970,18 @@ checksum = "ba3a3adc5c275d719af8cb4272ea1c4a6d668a777f37e115f6d11ddbc1c8e0e7"
dependencies = [ dependencies = [
"bitflags 1.3.2", "bitflags 1.3.2",
"core-foundation 0.9.4", "core-foundation 0.9.4",
"system-configuration-sys", "system-configuration-sys 0.5.0",
]
[[package]]
name = "system-configuration"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b"
dependencies = [
"bitflags 2.13.0",
"core-foundation 0.9.4",
"system-configuration-sys 0.6.0",
] ]
[[package]] [[package]]
@ -4981,6 +4994,16 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "system-configuration-sys"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]] [[package]]
name = "system-deps" name = "system-deps"
version = "6.2.2" version = "6.2.2"
@ -6876,8 +6899,8 @@ dependencies = [
"windows-implement", "windows-implement",
"windows-interface", "windows-interface",
"windows-link 0.1.3", "windows-link 0.1.3",
"windows-result", "windows-result 0.3.4",
"windows-strings", "windows-strings 0.4.2",
] ]
[[package]] [[package]]
@ -6935,6 +6958,17 @@ dependencies = [
"windows-link 0.1.3", "windows-link 0.1.3",
] ]
[[package]]
name = "windows-registry"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720"
dependencies = [
"windows-link 0.2.1",
"windows-result 0.4.1",
"windows-strings 0.5.1",
]
[[package]] [[package]]
name = "windows-result" name = "windows-result"
version = "0.3.4" version = "0.3.4"
@ -6944,6 +6978,15 @@ dependencies = [
"windows-link 0.1.3", "windows-link 0.1.3",
] ]
[[package]]
name = "windows-result"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
dependencies = [
"windows-link 0.2.1",
]
[[package]] [[package]]
name = "windows-strings" name = "windows-strings"
version = "0.4.2" version = "0.4.2"
@ -6953,6 +6996,15 @@ dependencies = [
"windows-link 0.1.3", "windows-link 0.1.3",
] ]
[[package]]
name = "windows-strings"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
dependencies = [
"windows-link 0.2.1",
]
[[package]] [[package]]
name = "windows-sys" name = "windows-sys"
version = "0.45.0" version = "0.45.0"

View file

@ -23,7 +23,7 @@ serde_json = "1"
serde_yaml = "0.9" serde_yaml = "0.9"
tokio = { version = "1", features = ["full"] } tokio = { version = "1", features = ["full"] }
dirs = "5" dirs = "5"
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "stream"] } reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "stream", "system-proxy", "socks"] }
hmac = "0.12" hmac = "0.12"
sha1 = "0.10" sha1 = "0.10"
base64 = "0.22" base64 = "0.22"

View file

@ -1,12 +1,18 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::time::Duration;
use tauri::{Emitter, Manager, WebviewWindow}; use tauri::{Emitter, Manager, WebviewWindow};
const TARBALL_URL: &str = const TARBALL_URLS: &[&str] = &[
"https://github.com/K-Dense-AI/scientific-agent-skills/archive/refs/heads/main.tar.gz"; "https://github.com/K-Dense-AI/scientific-agent-skills/archive/refs/heads/main.tar.gz",
const SKILLS_DOWNLOAD_TIMEOUT_SECS: u64 = 120; "https://codeload.github.com/K-Dense-AI/scientific-agent-skills/tar.gz/refs/heads/main",
"https://github.com/K-Dense-AI/claude-scientific-skills/archive/refs/heads/main.tar.gz",
];
const SKILLS_DOWNLOAD_ATTEMPTS: usize = 3;
const SKILLS_DOWNLOAD_TIMEOUT_SECS: u64 = 240;
const SKILLS_CONNECT_TIMEOUT_SECS: u64 = 20; const SKILLS_CONNECT_TIMEOUT_SECS: u64 = 20;
const SKILLS_INSTALL_TIMEOUT_SECS: u64 = 180; const SKILLS_INSTALL_TIMEOUT_SECS: u64 = 420;
const SKILL_CONTENT_TIMEOUT_SECS: u64 = 45;
const RAW_SKILL_URLS: &[&str] = &[ const RAW_SKILL_URLS: &[&str] = &[
"https://raw.githubusercontent.com/K-Dense-AI/scientific-agent-skills/main/skills", "https://raw.githubusercontent.com/K-Dense-AI/scientific-agent-skills/main/skills",
"https://raw.githubusercontent.com/K-Dense-AI/claude-scientific-skills/main/scientific-skills", "https://raw.githubusercontent.com/K-Dense-AI/claude-scientific-skills/main/scientific-skills",
@ -423,24 +429,404 @@ fn collect_skill_dirs(root: &Path, output: &mut Vec<PathBuf>) {
} }
} }
/// Download and extract tarball. #[derive(Clone, Copy, Debug, PartialEq, Eq)]
async fn download_tarball(window: &WebviewWindow, tmp_dir: &Path) -> Result<(), String> { enum ProxyKind {
let client = reqwest::Client::builder() All,
.connect_timeout(std::time::Duration::from_secs(SKILLS_CONNECT_TIMEOUT_SECS)) Http,
.timeout(std::time::Duration::from_secs(SKILLS_DOWNLOAD_TIMEOUT_SECS)) Https,
}
struct ProxyRule {
kind: ProxyKind,
url: String,
source: String,
}
fn first_env_value(names: &[&str]) -> Option<(String, String)> {
for name in names {
let Ok(value) = std::env::var(name) else {
continue;
};
let trimmed = value.trim();
if !trimmed.is_empty() {
return Some(((*name).to_string(), trimmed.to_string()));
}
}
None
}
fn normalize_proxy_url(raw: &str) -> Option<String> {
normalize_proxy_url_with_default(raw, "http")
}
fn normalize_proxy_url_with_default(raw: &str, default_scheme: &str) -> Option<String> {
let trimmed = raw.trim();
if trimmed.is_empty() {
return None;
}
if trimmed.contains("://") {
Some(trimmed.to_string())
} else {
Some(format!("{}://{}", default_scheme, trimmed))
}
}
fn explicit_env_proxy_rules() -> Vec<ProxyRule> {
let mut rules = Vec::new();
let mut has_https_proxy = false;
let mut has_all_proxy = false;
let mut http_proxy = None;
if let Some((source, raw)) = first_env_value(&["HTTPS_PROXY", "https_proxy"]) {
if let Some(url) = normalize_proxy_url(&raw) {
rules.push(ProxyRule {
kind: ProxyKind::Https,
url,
source,
});
has_https_proxy = true;
}
}
if let Some((source, raw)) = first_env_value(&["HTTP_PROXY", "http_proxy"]) {
if let Some(url) = normalize_proxy_url(&raw) {
http_proxy = Some((source.clone(), url.clone()));
rules.push(ProxyRule {
kind: ProxyKind::Http,
url,
source,
});
}
}
if let Some((source, raw)) = first_env_value(&["ALL_PROXY", "all_proxy"]) {
if let Some(url) = normalize_proxy_url(&raw) {
rules.push(ProxyRule {
kind: ProxyKind::All,
url,
source,
});
has_all_proxy = true;
}
}
if !has_https_proxy && !has_all_proxy {
if let Some((source, url)) = http_proxy {
rules.insert(
0,
ProxyRule {
kind: ProxyKind::Https,
url,
source: format!("{} (HTTPS fallback)", source),
},
);
}
}
rules
}
#[cfg(target_os = "windows")]
fn windows_proxy_override_to_no_proxy(raw: &str) -> Option<reqwest::NoProxy> {
let entries = raw
.split([';', ','])
.filter_map(|part| {
let trimmed = part.trim();
if trimmed.is_empty() {
return None;
}
if trimmed.eq_ignore_ascii_case("<local>") {
return Some("localhost,127.0.0.1,::1".to_string());
}
if trimmed == "*" {
return Some(trimmed.to_string());
}
if trimmed.contains('*') {
return trimmed
.strip_prefix("*.")
.map(|domain| format!(".{}", domain.trim_start_matches('.')));
}
Some(trimmed.to_string())
})
.collect::<Vec<_>>();
if entries.is_empty() {
None
} else {
reqwest::NoProxy::from_string(&entries.join(","))
}
}
#[cfg(target_os = "windows")]
fn windows_system_no_proxy() -> Option<reqwest::NoProxy> {
use winreg::enums::HKEY_CURRENT_USER;
use winreg::RegKey;
let settings = RegKey::predef(HKEY_CURRENT_USER)
.open_subkey(r"Software\Microsoft\Windows\CurrentVersion\Internet Settings")
.ok()?;
let raw = settings.get_value::<String, _>("ProxyOverride").ok()?;
windows_proxy_override_to_no_proxy(&raw)
}
#[cfg(target_os = "windows")]
fn parse_windows_proxy_server(raw: &str) -> Vec<ProxyRule> {
let trimmed = raw.trim();
if trimmed.is_empty() {
return Vec::new();
}
if !trimmed.contains('=') {
return normalize_proxy_url(trimmed)
.map(|url| {
vec![ProxyRule {
kind: ProxyKind::All,
url,
source: "Windows system proxy".to_string(),
}]
})
.unwrap_or_default();
}
let mut rules = Vec::new();
for entry in trimmed.split(';') {
let Some((scheme, value)) = entry.split_once('=') else {
continue;
};
let scheme = scheme.trim();
let (kind, default_proxy_scheme) = match scheme.to_ascii_lowercase().as_str() {
"http" => (ProxyKind::Http, "http"),
"https" => (ProxyKind::Https, "http"),
"socks" | "socks5" => (ProxyKind::All, "socks5"),
"socks4" => (ProxyKind::All, "socks4"),
_ => continue,
};
let Some(url) = normalize_proxy_url_with_default(value, default_proxy_scheme) else {
continue;
};
rules.push(ProxyRule {
kind,
url,
source: format!("Windows system proxy ({})", scheme.trim()),
});
}
rules
}
#[cfg(target_os = "windows")]
fn windows_system_proxy_rules() -> Vec<ProxyRule> {
use winreg::enums::HKEY_CURRENT_USER;
use winreg::RegKey;
let Ok(settings) = RegKey::predef(HKEY_CURRENT_USER)
.open_subkey(r"Software\Microsoft\Windows\CurrentVersion\Internet Settings")
else {
return Vec::new();
};
let proxy_enabled = settings.get_value::<u32, _>("ProxyEnable").unwrap_or(0) != 0;
if !proxy_enabled {
return Vec::new();
}
settings
.get_value::<String, _>("ProxyServer")
.map(|raw| parse_windows_proxy_server(&raw))
.unwrap_or_default()
}
#[cfg(not(target_os = "windows"))]
fn windows_system_proxy_rules() -> Vec<ProxyRule> {
Vec::new()
}
#[cfg(not(target_os = "windows"))]
fn windows_system_no_proxy() -> Option<reqwest::NoProxy> {
None
}
fn redacted_proxy_url(url: &str) -> String {
let Ok(mut parsed) = reqwest::Url::parse(url) else {
return "<invalid proxy URL>".to_string();
};
if !parsed.username().is_empty() {
let _ = parsed.set_username("***");
if parsed.password().is_some() {
let _ = parsed.set_password(Some("***"));
}
}
parsed.to_string()
}
fn add_proxy_rule(
builder: reqwest::ClientBuilder,
rule: &ProxyRule,
no_proxy: Option<reqwest::NoProxy>,
) -> Result<reqwest::ClientBuilder, String> {
let proxy = match rule.kind {
ProxyKind::All => reqwest::Proxy::all(&rule.url),
ProxyKind::Http => reqwest::Proxy::http(&rule.url),
ProxyKind::Https => reqwest::Proxy::https(&rule.url),
}
.map_err(|e| {
format!(
"Invalid proxy from {} ({}): {}",
rule.source,
redacted_proxy_url(&rule.url),
e
)
})?;
let proxy = proxy.no_proxy(no_proxy);
Ok(builder.proxy(proxy))
}
fn configure_proxy_for_client(
mut builder: reqwest::ClientBuilder,
window: Option<&WebviewWindow>,
) -> Result<reqwest::ClientBuilder, String> {
let mut rules = explicit_env_proxy_rules();
let mut no_proxy = reqwest::NoProxy::from_env();
if rules.is_empty() {
let windows_rules = windows_system_proxy_rules();
if !windows_rules.is_empty() {
rules = windows_rules;
no_proxy = windows_system_no_proxy();
}
}
if rules.is_empty() {
if let Some(window) = window {
emit_log(window, "Using system proxy settings when available");
}
return Ok(builder);
}
if let Some(window) = window {
for rule in &rules {
emit_log(
window,
&format!(
"Using proxy from {}: {}",
rule.source,
redacted_proxy_url(&rule.url)
),
);
}
}
for rule in &rules {
builder = add_proxy_rule(builder, rule, no_proxy.clone())?;
}
Ok(builder)
}
fn build_skills_http_client(
timeout_secs: u64,
window: Option<&WebviewWindow>,
) -> Result<reqwest::Client, String> {
let builder = reqwest::Client::builder()
.connect_timeout(Duration::from_secs(SKILLS_CONNECT_TIMEOUT_SECS))
.timeout(Duration::from_secs(timeout_secs));
configure_proxy_for_client(builder, window)?
.build() .build()
.map_err(|e| format!("Failed to create download client: {}", e))?; .map_err(|e| format!("Failed to create download client: {}", e))
}
fn tarball_source_label(url: &str) -> &'static str {
if url.contains("codeload.github.com") {
"GitHub codeload"
} else if url.contains("claude-scientific-skills") {
"legacy GitHub archive"
} else {
"GitHub archive"
}
}
fn reset_download_workspace(tmp_dir: &Path) {
let _ = std::fs::remove_dir_all(tmp_dir.join("repo"));
let _ = std::fs::remove_dir_all(tmp_dir.join("repo-raw"));
}
fn find_extracted_repo_dir(raw_dir: &Path) -> Option<PathBuf> {
let mut candidates = Vec::new();
let entries = std::fs::read_dir(raw_dir).ok()?;
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
candidates.push(path);
}
}
candidates.sort();
for candidate in &candidates {
if find_skills_source(candidate).is_some() {
return Some(candidate.clone());
}
}
candidates.into_iter().next()
}
fn unpack_tarball(bytes: &[u8], tmp_dir: &Path) -> Result<(), String> {
reset_download_workspace(tmp_dir);
let raw_dir = tmp_dir.join("repo-raw");
std::fs::create_dir_all(&raw_dir)
.map_err(|e| format!("Failed to create extraction dir: {}", e))?;
let decoder = flate2::read::GzDecoder::new(bytes);
let mut archive = tar::Archive::new(decoder);
archive
.unpack(&raw_dir)
.map_err(|e| format!("Failed to extract tarball: {}", e))?;
let repo_source = find_extracted_repo_dir(&raw_dir)
.ok_or_else(|| "Downloaded tarball did not contain a repository directory".to_string())?;
std::fs::rename(&repo_source, tmp_dir.join("repo"))
.map_err(|e| format!("Failed to prepare extracted repo: {}", e))?;
let _ = std::fs::remove_dir_all(&raw_dir);
Ok(())
}
async fn download_tarball_once(
client: &reqwest::Client,
window: &WebviewWindow,
tmp_dir: &Path,
url: &str,
) -> Result<(), String> {
reset_download_workspace(tmp_dir);
let source_label = tarball_source_label(url);
emit_log(window, &format!("Downloading from {}...", source_label));
let mut response = client let mut response = client
.get(TARBALL_URL) .get(url)
.header(reqwest::header::USER_AGENT, "ClaudePrism skills installer") .header(reqwest::header::USER_AGENT, "ClaudePrism skills installer")
.send() .send()
.await .await
.map_err(|e| format!("Failed to download tarball: {}", e))?; .map_err(|e| format!("Failed to start download: {}", e))?;
if !response.status().is_success() { if !response.status().is_success() {
return Err(format!( return Err(format!(
"Tarball download failed with status: {}", "Download failed with status: {}",
response.status() response.status()
)); ));
} }
@ -454,7 +840,7 @@ async fn download_tarball(window: &WebviewWindow, tmp_dir: &Path) -> Result<(),
while let Some(chunk) = response while let Some(chunk) = response
.chunk() .chunk()
.await .await
.map_err(|e| format!("Failed to read tarball bytes: {}", e))? .map_err(|e| format!("Failed to read download bytes: {}", e))?
{ {
downloaded += chunk.len() as u64; downloaded += chunk.len() as u64;
bytes.extend_from_slice(&chunk); bytes.extend_from_slice(&chunk);
@ -473,28 +859,48 @@ async fn download_tarball(window: &WebviewWindow, tmp_dir: &Path) -> Result<(),
} }
} }
// Decompress gzip unpack_tarball(&bytes, tmp_dir)
let decoder = flate2::read::GzDecoder::new(&bytes[..]); }
let mut archive = tar::Archive::new(decoder);
archive /// Download and extract tarball.
.unpack(tmp_dir.join("repo-raw")) async fn download_tarball(window: &WebviewWindow, tmp_dir: &Path) -> Result<(), String> {
.map_err(|e| format!("Failed to extract tarball: {}", e))?; let client = build_skills_http_client(SKILLS_DOWNLOAD_TIMEOUT_SECS, Some(window))?;
// The tarball extracts to scientific-agent-skills-main/ let mut last_error = None;
// We need to find it and rename to repo/ for attempt in 1..=SKILLS_DOWNLOAD_ATTEMPTS {
let raw_dir = tmp_dir.join("repo-raw"); for url in TARBALL_URLS {
if let Ok(mut entries) = std::fs::read_dir(&raw_dir) { let label = tarball_source_label(url);
if let Some(Ok(entry)) = entries.next() { emit_log(
std::fs::rename(entry.path(), tmp_dir.join("repo")) window,
.map_err(|e| format!("Failed to rename extracted dir: {}", e))?; &format!(
"Download attempt {}/{} ({})",
attempt, SKILLS_DOWNLOAD_ATTEMPTS, label
),
);
match download_tarball_once(&client, window, tmp_dir, url).await {
Ok(()) => return Ok(()),
Err(e) => {
let message = format!("{} failed: {}", label, e);
emit_log(window, &message);
last_error = Some(message);
reset_download_workspace(tmp_dir);
}
}
}
if attempt < SKILLS_DOWNLOAD_ATTEMPTS {
let delay_secs = attempt as u64 * 2;
emit_log(window, &format!("Retrying in {} seconds...", delay_secs));
tokio::time::sleep(Duration::from_secs(delay_secs)).await;
} }
} }
// Clean up the raw extraction directory Err(format!(
let _ = std::fs::remove_dir_all(&raw_dir); "Failed to download skills after {} attempts. Last error: {}",
SKILLS_DOWNLOAD_ATTEMPTS,
Ok(()) last_error.unwrap_or_else(|| "unknown download error".to_string())
))
} }
fn contains_skill_dirs(path: &Path) -> bool { fn contains_skill_dirs(path: &Path) -> bool {
@ -526,6 +932,81 @@ fn find_skills_source(repo_dir: &Path) -> Option<PathBuf> {
None None
} }
fn skills_staging_dir(target_dir: &Path) -> PathBuf {
let parent = target_dir
.parent()
.map(Path::to_path_buf)
.unwrap_or_else(|| PathBuf::from("."));
let target_name = target_dir
.file_name()
.and_then(|name| name.to_str())
.unwrap_or("skills");
parent.join(format!(
".{}-installing-{}",
target_name,
uuid::Uuid::new_v4().simple()
))
}
fn hidden_sibling_path(path: &Path, label: &str) -> PathBuf {
let parent = path
.parent()
.map(Path::to_path_buf)
.unwrap_or_else(|| PathBuf::from("."));
let name = path
.file_name()
.and_then(|name| name.to_str())
.unwrap_or("skill");
parent.join(format!(
".{}-{}-{}",
name,
label,
uuid::Uuid::new_v4().simple()
))
}
fn replace_dir_from_staging(staged: &Path, target: &Path) -> Result<(), String> {
let backup = hidden_sibling_path(target, "backup");
let had_existing = target.exists();
if had_existing {
std::fs::rename(target, &backup).map_err(|e| {
format!(
"Failed to prepare replacement for {}: {}",
target.display(),
e
)
})?;
}
match std::fs::rename(staged, target) {
Ok(()) => {
if had_existing {
let _ = std::fs::remove_dir_all(&backup);
}
Ok(())
}
Err(e) => {
let restore_error = if had_existing {
std::fs::rename(&backup, target)
.err()
.map(|restore| format!(" Restore also failed: {}", restore))
} else {
None
};
Err(format!(
"Failed to install {}: {}{}",
target.display(),
e,
restore_error.unwrap_or_default()
))
}
}
}
/// Copy the skills directory from the downloaded repo to the target. /// Copy the skills directory from the downloaded repo to the target.
fn copy_skills(repo_dir: &Path, target_dir: &Path) -> Result<usize, String> { fn copy_skills(repo_dir: &Path, target_dir: &Path) -> Result<usize, String> {
let src = find_skills_source(repo_dir).ok_or_else(|| { let src = find_skills_source(repo_dir).ok_or_else(|| {
@ -539,26 +1020,60 @@ fn copy_skills(repo_dir: &Path, target_dir: &Path) -> Result<usize, String> {
std::fs::create_dir_all(target_dir) std::fs::create_dir_all(target_dir)
.map_err(|e| format!("Failed to create target dir: {}", e))?; .map_err(|e| format!("Failed to create target dir: {}", e))?;
let mut count = 0;
let mut skill_dirs = Vec::new(); let mut skill_dirs = Vec::new();
collect_skill_dirs(&src, &mut skill_dirs); collect_skill_dirs(&src, &mut skill_dirs);
skill_dirs.sort(); skill_dirs.sort();
for entry_path in skill_dirs { if skill_dirs.is_empty() {
let Some(skill_name) = entry_path.file_name().and_then(|name| name.to_str()) else { return Err("No skills found in downloaded repository".into());
continue;
};
let target_skill = target_dir.join(skill_name);
if target_skill.exists() {
std::fs::remove_dir_all(&target_skill)
.map_err(|e| format!("Failed to replace {}: {}", target_skill.display(), e))?;
}
copy_dir_recursive(&entry_path, &target_skill)?;
count += 1;
} }
Ok(count) let staging_dir = skills_staging_dir(target_dir);
std::fs::create_dir_all(&staging_dir)
.map_err(|e| format!("Failed to create staging dir: {}", e))?;
let mut staged_names = Vec::new();
let stage_result = (|| -> Result<(), String> {
for entry_path in &skill_dirs {
let Some(skill_name) = entry_path.file_name().and_then(|name| name.to_str()) else {
continue;
};
let staged_skill = staging_dir.join(skill_name);
copy_dir_recursive(entry_path, &staged_skill)?;
staged_names.push(skill_name.to_string());
}
Ok(())
})();
if let Err(e) = stage_result {
let _ = std::fs::remove_dir_all(&staging_dir);
return Err(e);
}
let replace_result = (|| -> Result<usize, String> {
let mut count = 0;
for skill_name in staged_names {
let staged_skill = staging_dir.join(&skill_name);
let target_skill = target_dir.join(&skill_name);
replace_dir_from_staging(&staged_skill, &target_skill).map_err(|e| {
format!(
"Failed to replace {} with staged skill {}: {}",
target_skill.display(),
skill_name,
e
)
})?;
count += 1;
}
Ok(count)
})();
let _ = std::fs::remove_dir_all(&staging_dir);
replace_result
} }
/// Recursively copy a directory. /// Recursively copy a directory.
@ -839,36 +1354,43 @@ async fn install_skills_to(
msg msg
})?; })?;
// Download via tarball (faster, no git/git-lfs dependency) let result = async {
emit_log(window, "Downloading skills..."); // Download via tarball (faster, no git/git-lfs dependency)
download_tarball(window, &tmp_dir).await.map_err(|e| { emit_log(window, "Downloading skills...");
emit_log(window, &format!("Download failed: {}", e)); download_tarball(window, &tmp_dir).await.map_err(|e| {
e emit_log(window, &format!("Download failed: {}", e));
})?; e
emit_log(window, "Download complete"); })?;
emit_log(window, "Download complete");
let repo_dir = tmp_dir.join("repo"); let repo_dir = tmp_dir.join("repo");
// Copy skills to target directory // Copy skills to target directory
emit_log(window, "Copying skills..."); emit_log(window, "Copying skills...");
let count = copy_skills(&repo_dir, target).map_err(|e| { let count = copy_skills(&repo_dir, target).map_err(|e| {
emit_log(window, &format!("Copy failed: {}", e)); emit_log(window, &format!("Copy failed: {}", e));
e e
})?; })?;
emit_log(window, &format!("Copied {} skills", count)); emit_log(window, &format!("Copied {} skills", count));
// Clean up temp directory let target_str = target.to_string_lossy().to_string();
let _ = std::fs::remove_dir_all(&tmp_dir);
emit_log(window, "Cleanup complete");
let target_str = target.to_string_lossy().to_string(); Ok(InstallResult {
success: true,
skills_installed: count,
target_dir: target_str.clone(),
message: format!("Successfully installed {} skills to {}", count, target_str),
})
}
.await;
Ok(InstallResult { match std::fs::remove_dir_all(&tmp_dir) {
success: true, Ok(_) => emit_log(window, "Cleanup complete"),
skills_installed: count, Err(e) if tmp_dir.exists() => emit_log(window, &format!("Cleanup failed: {}", e)),
target_dir: target_str.clone(), Err(_) => {}
message: format!("Successfully installed {} skills to {}", count, target_str), }
})
result
} }
#[tauri::command] #[tauri::command]
@ -1010,27 +1532,44 @@ pub async fn get_skill_content(
// Fallback: fetch from GitHub. The upstream project moved from // Fallback: fetch from GitHub. The upstream project moved from
// claude-scientific-skills/scientific-skills to scientific-agent-skills/skills. // claude-scientific-skills/scientific-skills to scientific-agent-skills/skills.
let mut last_status = None; let client = build_skills_http_client(SKILL_CONTENT_TIMEOUT_SECS, None)
.map_err(|e| format!("Failed to create GitHub client: {}", e))?;
let mut last_error = None;
for base_url in RAW_SKILL_URLS { for base_url in RAW_SKILL_URLS {
let url = format!("{}/{}/SKILL.md", base_url, skill_folder); for skill_file in ["SKILL.md", "skill.md"] {
let response = reqwest::get(&url) let url = format!("{}/{}/{}", base_url, skill_folder, skill_file);
.await let response = match client
.map_err(|e| format!("Failed to fetch from GitHub: {}", e))?; .get(&url)
.header(reqwest::header::USER_AGENT, "ClaudePrism skills viewer")
if response.status().is_success() { .send()
return response
.text()
.await .await
.map_err(|e| format!("Failed to read response: {}", e)); {
} Ok(response) => response,
Err(e) => {
last_error = Some(format!("{}: {}", url, e));
continue;
}
};
last_status = Some(response.status().to_string()); if response.status().is_success() {
match response.text().await {
Ok(text) => return Ok(text),
Err(e) => {
last_error = Some(format!("{}: failed to read response: {}", url, e));
continue;
}
}
}
last_error = Some(format!("{}: HTTP {}", url, response.status()));
}
} }
Err(format!( Err(format!(
"Skill '{}' not found (HTTP {})", "Skill '{}' not found. Last error: {}",
skill_folder, skill_folder,
last_status.unwrap_or_else(|| "unknown".to_string()) last_error.unwrap_or_else(|| "unknown".to_string())
)) ))
} }
@ -1065,6 +1604,43 @@ mod tests {
); );
} }
#[test]
fn test_normalize_proxy_url_defaults_to_http() {
assert_eq!(
normalize_proxy_url("127.0.0.1:7890"),
Some("http://127.0.0.1:7890".to_string())
);
assert_eq!(
normalize_proxy_url("socks5://127.0.0.1:7891"),
Some("socks5://127.0.0.1:7891".to_string())
);
assert_eq!(normalize_proxy_url(" "), None);
}
#[cfg(target_os = "windows")]
#[test]
fn test_parse_windows_proxy_server_single_proxy() {
let rules = parse_windows_proxy_server("127.0.0.1:7890");
assert_eq!(rules.len(), 1);
assert_eq!(rules[0].kind, ProxyKind::All);
assert_eq!(rules[0].url, "http://127.0.0.1:7890");
}
#[cfg(target_os = "windows")]
#[test]
fn test_parse_windows_proxy_server_per_scheme_proxy() {
let rules = parse_windows_proxy_server(
"http=127.0.0.1:7890;https=127.0.0.1:7890;socks=127.0.0.1:7891",
);
assert_eq!(rules.len(), 3);
assert_eq!(rules[0].kind, ProxyKind::Http);
assert_eq!(rules[0].url, "http://127.0.0.1:7890");
assert_eq!(rules[1].kind, ProxyKind::Https);
assert_eq!(rules[1].url, "http://127.0.0.1:7890");
assert_eq!(rules[2].kind, ProxyKind::All);
assert_eq!(rules[2].url, "socks5://127.0.0.1:7891");
}
#[test] #[test]
fn test_skill_categories_count() { fn test_skill_categories_count() {
let cats = skill_categories(); let cats = skill_categories();

View file

@ -78,8 +78,7 @@ export function ScientificSkillsOnboarding({
const [installedSkills, setInstalledSkills] = useState<SkillInfo[]>([]); const [installedSkills, setInstalledSkills] = useState<SkillInfo[]>([]);
const [isUninstalling, setIsUninstalling] = useState(false); const [isUninstalling, setIsUninstalling] = useState(false);
const [isImporting, setIsImporting] = useState(false); const [isImporting, setIsImporting] = useState(false);
const [confirmUninstallAllOpen, setConfirmUninstallAllOpen] = const [confirmUninstallAllOpen, setConfirmUninstallAllOpen] = useState(false);
useState(false);
const [deleteTarget, setDeleteTarget] = useState<SkillEntryData | null>(null); const [deleteTarget, setDeleteTarget] = useState<SkillEntryData | null>(null);
const [deletingSkillFolder, setDeletingSkillFolder] = useState<string | null>( const [deletingSkillFolder, setDeletingSkillFolder] = useState<string | null>(
null, null,
@ -195,10 +194,7 @@ export function ScientificSkillsOnboarding({
(line) => !line.startsWith("Preparing installer"), (line) => !line.startsWith("Preparing installer"),
); );
if (hasBackendLog) return previous; if (hasBackendLog) return previous;
return [ return [...previous, "Waiting for the installer command to start..."];
...previous,
"Waiting for the installer command to start...",
];
}); });
}, 2500); }, 2500);
@ -308,7 +304,7 @@ export function ScientificSkillsOnboarding({
}, [checkStatus, deleteTarget]); }, [checkStatus, deleteTarget]);
// ─── Installing / Complete state ─── // ─── Installing / Complete state ───
if (isInstalling || isComplete) { if (isInstalling || isComplete || error) {
return ( return (
<Dialog <Dialog
open open
@ -336,10 +332,16 @@ export function ScientificSkillsOnboarding({
<DialogTitle className="flex items-center gap-2 text-sm"> <DialogTitle className="flex items-center gap-2 text-sm">
{isComplete ? ( {isComplete ? (
<CheckCircle2Icon className="size-5 text-foreground" /> <CheckCircle2Icon className="size-5 text-foreground" />
) : error ? (
<AlertCircleIcon className="size-5 text-destructive" />
) : ( ) : (
<FlaskConicalIcon className="size-5 text-muted-foreground" /> <FlaskConicalIcon className="size-5 text-muted-foreground" />
)} )}
{isComplete ? "Installation Complete" : "Installing Skills"} {isComplete
? "Installation Complete"
: error
? "Installation Failed"
: "Installing Skills"}
</DialogTitle> </DialogTitle>
{isComplete && ( {isComplete && (
<DialogDescription> <DialogDescription>
@ -370,10 +372,7 @@ export function ScientificSkillsOnboarding({
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
onClick={() => { onClick={handleInstall}
setError(null);
setIsInstalling(false);
}}
className="gap-1.5" className="gap-1.5"
> >
<RefreshCwIcon className="size-3.5" /> <RefreshCwIcon className="size-3.5" />
@ -404,147 +403,149 @@ export function ScientificSkillsOnboarding({
showCloseButton={false} showCloseButton={false}
className="flex h-[min(36rem,calc(100vh-6rem))] w-[min(56rem,calc(100vw-4rem))] max-w-none flex-col gap-0 overflow-hidden p-0 sm:max-w-none" className="flex h-[min(36rem,calc(100vh-6rem))] w-[min(56rem,calc(100vw-4rem))] max-w-none flex-col gap-0 overflow-hidden p-0 sm:max-w-none"
> >
{/* Header */} {/* Header */}
<DialogHeader className="shrink-0 border-border border-b px-6 py-3"> <DialogHeader className="shrink-0 border-border border-b px-6 py-3">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<DialogTitle className="text-sm">Skills</DialogTitle> <DialogTitle className="text-sm">Skills</DialogTitle>
<DialogDescription className="mt-0.5 text-xs"> <DialogDescription className="mt-0.5 text-xs">
{totalSkills} skills across {displayCategories.length} groups - {totalSkills} skills across {displayCategories.length} groups
install curated scientific skills or import a local Claude skill. - install curated scientific skills or import a local Claude
Curated set powered by{" "} skill. Curated set powered by{" "}
<a <a
href="https://github.com/K-Dense-AI/scientific-agent-skills" href="https://github.com/K-Dense-AI/scientific-agent-skills"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="inline-flex items-center gap-0.5 underline decoration-border underline-offset-2 hover:text-foreground" className="inline-flex items-center gap-0.5 underline decoration-border underline-offset-2 hover:text-foreground"
>
K-Dense
<ExternalLinkIcon className="size-2.5" />
</a>
</DialogDescription>
</div>
<div className="flex shrink-0 items-center gap-2">
{isInstalled ? (
<>
<Badge variant="secondary" className="gap-1 text-xs">
<CheckCircle2Icon className="size-3" />
{status?.skill_count} installed
</Badge>
<Button
variant="outline"
size="sm"
onClick={handleInstall}
className="gap-1.5"
> >
<RefreshCwIcon className="size-3.5" /> K-Dense
Update <ExternalLinkIcon className="size-2.5" />
</Button> </a>
<Button </DialogDescription>
variant="outline"
size="sm"
onClick={() => setConfirmUninstallAllOpen(true)}
disabled={isUninstalling}
className="gap-1.5 text-destructive hover:text-destructive"
>
{isUninstalling ? (
<Loader2Icon className="size-3.5 animate-spin" />
) : (
<Trash2Icon className="size-3.5" />
)}
Uninstall
</Button>
</>
) : (
<Button size="sm" onClick={handleInstall} className="gap-1.5">
<DownloadIcon className="size-3.5" />
Install All
</Button>
)}
<Button
variant="outline"
size="sm"
onClick={handleImportSkill}
disabled={isImporting || isInstalling || isUninstalling}
className="gap-1.5 text-green-600 hover:text-green-700 dark:text-green-400 dark:hover:text-green-300"
>
{isImporting ? (
<Loader2Icon className="size-3.5 animate-spin" />
) : (
<FolderPlusIcon className="size-3.5" />
)}
Import Skill
</Button>
</div>
</div>
</DialogHeader>
{/* Body — sidebar + detail */}
<div className="flex flex-1 overflow-hidden">
{/* Category sidebar */}
<nav className="w-64 max-w-64 shrink-0 overflow-hidden border-border border-r">
<ScrollArea className="h-full w-full overflow-hidden [&_[data-slot=scroll-area-scrollbar]]:hidden">
<div className="box-border flex w-full min-w-0 flex-col gap-0.5 overflow-x-hidden p-2">
{displayCategories.map((cat) => {
const Icon = ICON_MAP[cat.icon] || FlaskConicalIcon;
const isActive = selectedId === cat.id;
return (
<button
key={cat.id}
onClick={() => setSelectedId(cat.id)}
className={cn(
"box-border grid w-full max-w-full min-w-0 grid-cols-[1rem_minmax(0,1fr)] items-center gap-2.5 overflow-hidden rounded-lg px-3 py-2 text-left text-sm transition-colors",
isActive
? "bg-accent font-medium text-accent-foreground"
: "text-muted-foreground hover:bg-accent/50 hover:text-foreground",
)}
title={cat.name}
>
<Icon className="size-4 shrink-0" />
<span className="block min-w-0 truncate">{cat.name}</span>
</button>
);
})}
</div> </div>
</ScrollArea> <div className="flex shrink-0 items-center gap-2">
</nav> {isInstalled ? (
<>
<Badge variant="secondary" className="gap-1 text-xs">
<CheckCircle2Icon className="size-3" />
{status?.skill_count} installed
</Badge>
<Button
variant="outline"
size="sm"
onClick={handleInstall}
className="gap-1.5"
>
<RefreshCwIcon className="size-3.5" />
Update
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setConfirmUninstallAllOpen(true)}
disabled={isUninstalling}
className="gap-1.5 text-destructive hover:text-destructive"
>
{isUninstalling ? (
<Loader2Icon className="size-3.5 animate-spin" />
) : (
<Trash2Icon className="size-3.5" />
)}
Uninstall
</Button>
</>
) : (
<Button size="sm" onClick={handleInstall} className="gap-1.5">
<DownloadIcon className="size-3.5" />
Install All
</Button>
)}
<Button
variant="outline"
size="sm"
onClick={handleImportSkill}
disabled={isImporting || isInstalling || isUninstalling}
className="gap-1.5 text-green-600 hover:text-green-700 dark:text-green-400 dark:hover:text-green-300"
>
{isImporting ? (
<Loader2Icon className="size-3.5 animate-spin" />
) : (
<FolderPlusIcon className="size-3.5" />
)}
Import Skill
</Button>
</div>
</div>
</DialogHeader>
{/* Detail panel */} {/* Body — sidebar + detail */}
<div className="flex flex-1 flex-col overflow-hidden"> <div className="flex flex-1 overflow-hidden">
{selected ? ( {/* Category sidebar */}
<ScrollArea className="flex-1"> <nav className="w-64 max-w-64 shrink-0 overflow-hidden border-border border-r">
<div className="p-6"> <ScrollArea className="h-full w-full overflow-hidden [&_[data-slot=scroll-area-scrollbar]]:hidden">
<CategoryDetail <div className="box-border flex w-full min-w-0 flex-col gap-0.5 overflow-x-hidden p-2">
category={selected} {displayCategories.map((cat) => {
isInstalled={isInstalled} const Icon = ICON_MAP[cat.icon] || FlaskConicalIcon;
installedSkillFolders={installedSkillFolders} const isActive = selectedId === cat.id;
deletingSkillFolder={deletingSkillFolder} return (
onDeleteSkill={setDeleteTarget} <button
/> key={cat.id}
onClick={() => setSelectedId(cat.id)}
className={cn(
"box-border grid w-full min-w-0 max-w-full grid-cols-[1rem_minmax(0,1fr)] items-center gap-2.5 overflow-hidden rounded-lg px-3 py-2 text-left text-sm transition-colors",
isActive
? "bg-accent font-medium text-accent-foreground"
: "text-muted-foreground hover:bg-accent/50 hover:text-foreground",
)}
title={cat.name}
>
<Icon className="size-4 shrink-0" />
<span className="block min-w-0 truncate">
{cat.name}
</span>
</button>
);
})}
</div> </div>
</ScrollArea> </ScrollArea>
) : ( </nav>
<div className="flex flex-1 items-center justify-center text-muted-foreground text-sm">
Select a category
</div>
)}
</div>
</div>
{/* Footer */} {/* Detail panel */}
<div className="flex shrink-0 items-center justify-between border-border border-t bg-muted/20 px-6 py-2.5"> <div className="flex flex-1 flex-col overflow-hidden">
<p className="font-mono text-[11px] text-muted-foreground/60"> {selected ? (
{status?.location ?? "~/.claude/skills/"} <ScrollArea className="flex-1">
</p> <div className="p-6">
<Button <CategoryDetail
variant="ghost" category={selected}
size="sm" isInstalled={isInstalled}
onClick={onClose} installedSkillFolders={installedSkillFolders}
className="text-muted-foreground" deletingSkillFolder={deletingSkillFolder}
> onDeleteSkill={setDeleteTarget}
Close />
</Button> </div>
</div> </ScrollArea>
) : (
<div className="flex flex-1 items-center justify-center text-muted-foreground text-sm">
Select a category
</div>
)}
</div>
</div>
{/* Footer */}
<div className="flex shrink-0 items-center justify-between border-border border-t bg-muted/20 px-6 py-2.5">
<p className="font-mono text-[11px] text-muted-foreground/60">
{status?.location ?? "~/.claude/skills/"}
</p>
<Button
variant="ghost"
size="sm"
onClick={onClose}
className="text-muted-foreground"
>
Close
</Button>
</div>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
@ -558,8 +559,8 @@ export function ScientificSkillsOnboarding({
<DialogHeader> <DialogHeader>
<DialogTitle>Delete Skill</DialogTitle> <DialogTitle>Delete Skill</DialogTitle>
<DialogDescription> <DialogDescription>
Delete {deleteTarget?.name ?? "this skill"} from Delete {deleteTarget?.name ?? "this skill"} from ~/.claude/skills.
~/.claude/skills. This cannot be undone. This cannot be undone.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="rounded-lg border border-border/60 bg-muted/30 px-3 py-2 font-mono text-muted-foreground text-xs"> <div className="rounded-lg border border-border/60 bg-muted/30 px-3 py-2 font-mono text-muted-foreground text-xs">
@ -823,7 +824,7 @@ function CategoryDetail({
event.stopPropagation(); event.stopPropagation();
onDeleteSkill(skill); onDeleteSkill(skill);
}} }}
className="mr-1 flex size-7 shrink-0 items-center justify-center rounded-md text-muted-foreground opacity-70 transition-colors hover:bg-destructive/10 hover:text-destructive group-hover:opacity-100 disabled:opacity-50" className="mr-1 flex size-7 shrink-0 items-center justify-center rounded-md text-muted-foreground opacity-70 transition-colors hover:bg-destructive/10 hover:text-destructive disabled:opacity-50 group-hover:opacity-100"
> >
{isDeleting ? ( {isDeleting ? (
<Loader2Icon className="size-3.5 animate-spin" /> <Loader2Icon className="size-3.5 animate-spin" />