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",
"pin-project-lite",
"socket2 0.5.10",
"system-configuration 0.7.0",
"tokio",
"tower-service",
"tracing",
"windows-registry",
]
[[package]]
@ -4054,7 +4056,7 @@ dependencies = [
"serde_json",
"serde_urlencoded",
"sync_wrapper 0.1.2",
"system-configuration",
"system-configuration 0.5.1",
"tokio",
"tokio-native-tls",
"tower-service",
@ -4968,7 +4970,18 @@ checksum = "ba3a3adc5c275d719af8cb4272ea1c4a6d668a777f37e115f6d11ddbc1c8e0e7"
dependencies = [
"bitflags 1.3.2",
"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]]
@ -4981,6 +4994,16 @@ dependencies = [
"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]]
name = "system-deps"
version = "6.2.2"
@ -6876,8 +6899,8 @@ dependencies = [
"windows-implement",
"windows-interface",
"windows-link 0.1.3",
"windows-result",
"windows-strings",
"windows-result 0.3.4",
"windows-strings 0.4.2",
]
[[package]]
@ -6935,6 +6958,17 @@ dependencies = [
"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]]
name = "windows-result"
version = "0.3.4"
@ -6944,6 +6978,15 @@ dependencies = [
"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]]
name = "windows-strings"
version = "0.4.2"
@ -6953,6 +6996,15 @@ dependencies = [
"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]]
name = "windows-sys"
version = "0.45.0"

View file

@ -23,7 +23,7 @@ serde_json = "1"
serde_yaml = "0.9"
tokio = { version = "1", features = ["full"] }
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"
sha1 = "0.10"
base64 = "0.22"

View file

@ -1,12 +1,18 @@
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use std::time::Duration;
use tauri::{Emitter, Manager, WebviewWindow};
const TARBALL_URL: &str =
"https://github.com/K-Dense-AI/scientific-agent-skills/archive/refs/heads/main.tar.gz";
const SKILLS_DOWNLOAD_TIMEOUT_SECS: u64 = 120;
const TARBALL_URLS: &[&str] = &[
"https://github.com/K-Dense-AI/scientific-agent-skills/archive/refs/heads/main.tar.gz",
"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_INSTALL_TIMEOUT_SECS: u64 = 180;
const SKILLS_INSTALL_TIMEOUT_SECS: u64 = 420;
const SKILL_CONTENT_TIMEOUT_SECS: u64 = 45;
const RAW_SKILL_URLS: &[&str] = &[
"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",
@ -423,24 +429,404 @@ fn collect_skill_dirs(root: &Path, output: &mut Vec<PathBuf>) {
}
}
/// Download and extract tarball.
async fn download_tarball(window: &WebviewWindow, tmp_dir: &Path) -> Result<(), String> {
let client = reqwest::Client::builder()
.connect_timeout(std::time::Duration::from_secs(SKILLS_CONNECT_TIMEOUT_SECS))
.timeout(std::time::Duration::from_secs(SKILLS_DOWNLOAD_TIMEOUT_SECS))
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum ProxyKind {
All,
Http,
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()
.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
.get(TARBALL_URL)
.get(url)
.header(reqwest::header::USER_AGENT, "ClaudePrism skills installer")
.send()
.await
.map_err(|e| format!("Failed to download tarball: {}", e))?;
.map_err(|e| format!("Failed to start download: {}", e))?;
if !response.status().is_success() {
return Err(format!(
"Tarball download failed with status: {}",
"Download failed with status: {}",
response.status()
));
}
@ -454,7 +840,7 @@ async fn download_tarball(window: &WebviewWindow, tmp_dir: &Path) -> Result<(),
while let Some(chunk) = response
.chunk()
.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;
bytes.extend_from_slice(&chunk);
@ -473,28 +859,48 @@ async fn download_tarball(window: &WebviewWindow, tmp_dir: &Path) -> Result<(),
}
}
// Decompress gzip
let decoder = flate2::read::GzDecoder::new(&bytes[..]);
let mut archive = tar::Archive::new(decoder);
unpack_tarball(&bytes, tmp_dir)
}
archive
.unpack(tmp_dir.join("repo-raw"))
.map_err(|e| format!("Failed to extract tarball: {}", e))?;
/// Download and extract tarball.
async fn download_tarball(window: &WebviewWindow, tmp_dir: &Path) -> Result<(), String> {
let client = build_skills_http_client(SKILLS_DOWNLOAD_TIMEOUT_SECS, Some(window))?;
// The tarball extracts to scientific-agent-skills-main/
// We need to find it and rename to repo/
let raw_dir = tmp_dir.join("repo-raw");
if let Ok(mut entries) = std::fs::read_dir(&raw_dir) {
if let Some(Ok(entry)) = entries.next() {
std::fs::rename(entry.path(), tmp_dir.join("repo"))
.map_err(|e| format!("Failed to rename extracted dir: {}", e))?;
let mut last_error = None;
for attempt in 1..=SKILLS_DOWNLOAD_ATTEMPTS {
for url in TARBALL_URLS {
let label = tarball_source_label(url);
emit_log(
window,
&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
let _ = std::fs::remove_dir_all(&raw_dir);
Ok(())
Err(format!(
"Failed to download skills after {} attempts. Last error: {}",
SKILLS_DOWNLOAD_ATTEMPTS,
last_error.unwrap_or_else(|| "unknown download error".to_string())
))
}
fn contains_skill_dirs(path: &Path) -> bool {
@ -526,6 +932,81 @@ fn find_skills_source(repo_dir: &Path) -> Option<PathBuf> {
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.
fn copy_skills(repo_dir: &Path, target_dir: &Path) -> Result<usize, String> {
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)
.map_err(|e| format!("Failed to create target dir: {}", e))?;
let mut count = 0;
let mut skill_dirs = Vec::new();
collect_skill_dirs(&src, &mut skill_dirs);
skill_dirs.sort();
for entry_path in skill_dirs {
let Some(skill_name) = entry_path.file_name().and_then(|name| name.to_str()) else {
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;
if skill_dirs.is_empty() {
return Err("No skills found in downloaded repository".into());
}
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.
@ -839,36 +1354,43 @@ async fn install_skills_to(
msg
})?;
// Download via tarball (faster, no git/git-lfs dependency)
emit_log(window, "Downloading skills...");
download_tarball(window, &tmp_dir).await.map_err(|e| {
emit_log(window, &format!("Download failed: {}", e));
e
})?;
emit_log(window, "Download complete");
let result = async {
// Download via tarball (faster, no git/git-lfs dependency)
emit_log(window, "Downloading skills...");
download_tarball(window, &tmp_dir).await.map_err(|e| {
emit_log(window, &format!("Download failed: {}", e));
e
})?;
emit_log(window, "Download complete");
let repo_dir = tmp_dir.join("repo");
let repo_dir = tmp_dir.join("repo");
// Copy skills to target directory
emit_log(window, "Copying skills...");
let count = copy_skills(&repo_dir, target).map_err(|e| {
emit_log(window, &format!("Copy failed: {}", e));
e
})?;
emit_log(window, &format!("Copied {} skills", count));
// Copy skills to target directory
emit_log(window, "Copying skills...");
let count = copy_skills(&repo_dir, target).map_err(|e| {
emit_log(window, &format!("Copy failed: {}", e));
e
})?;
emit_log(window, &format!("Copied {} skills", count));
// Clean up temp directory
let _ = std::fs::remove_dir_all(&tmp_dir);
emit_log(window, "Cleanup complete");
let target_str = target.to_string_lossy().to_string();
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 {
success: true,
skills_installed: count,
target_dir: target_str.clone(),
message: format!("Successfully installed {} skills to {}", count, target_str),
})
match std::fs::remove_dir_all(&tmp_dir) {
Ok(_) => emit_log(window, "Cleanup complete"),
Err(e) if tmp_dir.exists() => emit_log(window, &format!("Cleanup failed: {}", e)),
Err(_) => {}
}
result
}
#[tauri::command]
@ -1010,27 +1532,44 @@ pub async fn get_skill_content(
// Fallback: fetch from GitHub. The upstream project moved from
// 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 {
let url = format!("{}/{}/SKILL.md", base_url, skill_folder);
let response = reqwest::get(&url)
.await
.map_err(|e| format!("Failed to fetch from GitHub: {}", e))?;
if response.status().is_success() {
return response
.text()
for skill_file in ["SKILL.md", "skill.md"] {
let url = format!("{}/{}/{}", base_url, skill_folder, skill_file);
let response = match client
.get(&url)
.header(reqwest::header::USER_AGENT, "ClaudePrism skills viewer")
.send()
.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!(
"Skill '{}' not found (HTTP {})",
"Skill '{}' not found. Last error: {}",
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]
fn test_skill_categories_count() {
let cats = skill_categories();

View file

@ -78,8 +78,7 @@ export function ScientificSkillsOnboarding({
const [installedSkills, setInstalledSkills] = useState<SkillInfo[]>([]);
const [isUninstalling, setIsUninstalling] = useState(false);
const [isImporting, setIsImporting] = useState(false);
const [confirmUninstallAllOpen, setConfirmUninstallAllOpen] =
useState(false);
const [confirmUninstallAllOpen, setConfirmUninstallAllOpen] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<SkillEntryData | null>(null);
const [deletingSkillFolder, setDeletingSkillFolder] = useState<string | null>(
null,
@ -195,10 +194,7 @@ export function ScientificSkillsOnboarding({
(line) => !line.startsWith("Preparing installer"),
);
if (hasBackendLog) return previous;
return [
...previous,
"Waiting for the installer command to start...",
];
return [...previous, "Waiting for the installer command to start..."];
});
}, 2500);
@ -308,7 +304,7 @@ export function ScientificSkillsOnboarding({
}, [checkStatus, deleteTarget]);
// ─── Installing / Complete state ───
if (isInstalling || isComplete) {
if (isInstalling || isComplete || error) {
return (
<Dialog
open
@ -336,10 +332,16 @@ export function ScientificSkillsOnboarding({
<DialogTitle className="flex items-center gap-2 text-sm">
{isComplete ? (
<CheckCircle2Icon className="size-5 text-foreground" />
) : error ? (
<AlertCircleIcon className="size-5 text-destructive" />
) : (
<FlaskConicalIcon className="size-5 text-muted-foreground" />
)}
{isComplete ? "Installation Complete" : "Installing Skills"}
{isComplete
? "Installation Complete"
: error
? "Installation Failed"
: "Installing Skills"}
</DialogTitle>
{isComplete && (
<DialogDescription>
@ -370,10 +372,7 @@ export function ScientificSkillsOnboarding({
<Button
variant="outline"
size="sm"
onClick={() => {
setError(null);
setIsInstalling(false);
}}
onClick={handleInstall}
className="gap-1.5"
>
<RefreshCwIcon className="size-3.5" />
@ -404,147 +403,149 @@ export function ScientificSkillsOnboarding({
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"
>
{/* Header */}
<DialogHeader className="shrink-0 border-border border-b px-6 py-3">
<div className="flex items-center gap-4">
<div className="min-w-0 flex-1">
<DialogTitle className="text-sm">Skills</DialogTitle>
<DialogDescription className="mt-0.5 text-xs">
{totalSkills} skills across {displayCategories.length} groups -
install curated scientific skills or import a local Claude skill.
Curated set powered by{" "}
<a
href="https://github.com/K-Dense-AI/scientific-agent-skills"
target="_blank"
rel="noopener noreferrer"
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"
{/* Header */}
<DialogHeader className="shrink-0 border-border border-b px-6 py-3">
<div className="flex items-center gap-4">
<div className="min-w-0 flex-1">
<DialogTitle className="text-sm">Skills</DialogTitle>
<DialogDescription className="mt-0.5 text-xs">
{totalSkills} skills across {displayCategories.length} groups
- install curated scientific skills or import a local Claude
skill. Curated set powered by{" "}
<a
href="https://github.com/K-Dense-AI/scientific-agent-skills"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-0.5 underline decoration-border underline-offset-2 hover:text-foreground"
>
<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>
{/* 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>
);
})}
K-Dense
<ExternalLinkIcon className="size-2.5" />
</a>
</DialogDescription>
</div>
</ScrollArea>
</nav>
<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" />
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 */}
<div className="flex flex-1 flex-col overflow-hidden">
{selected ? (
<ScrollArea className="flex-1">
<div className="p-6">
<CategoryDetail
category={selected}
isInstalled={isInstalled}
installedSkillFolders={installedSkillFolders}
deletingSkillFolder={deletingSkillFolder}
onDeleteSkill={setDeleteTarget}
/>
{/* 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 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>
</ScrollArea>
) : (
<div className="flex flex-1 items-center justify-center text-muted-foreground text-sm">
Select a category
</div>
)}
</div>
</div>
</nav>
{/* 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>
{/* Detail panel */}
<div className="flex flex-1 flex-col overflow-hidden">
{selected ? (
<ScrollArea className="flex-1">
<div className="p-6">
<CategoryDetail
category={selected}
isInstalled={isInstalled}
installedSkillFolders={installedSkillFolders}
deletingSkillFolder={deletingSkillFolder}
onDeleteSkill={setDeleteTarget}
/>
</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>
</Dialog>
@ -558,8 +559,8 @@ export function ScientificSkillsOnboarding({
<DialogHeader>
<DialogTitle>Delete Skill</DialogTitle>
<DialogDescription>
Delete {deleteTarget?.name ?? "this skill"} from
~/.claude/skills. This cannot be undone.
Delete {deleteTarget?.name ?? "this skill"} from ~/.claude/skills.
This cannot be undone.
</DialogDescription>
</DialogHeader>
<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();
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 ? (
<Loader2Icon className="size-3.5 animate-spin" />