mirror of
https://github.com/delibae/claude-prism.git
synced 2026-08-28 05:14:59 +00:00
feat: enhance skills downloading and proxy configuration
This commit is contained in:
parent
bcbbd449dc
commit
69b5e960e8
4 changed files with 868 additions and 239 deletions
60
apps/desktop/src-tauri/Cargo.lock
generated
60
apps/desktop/src-tauri/Cargo.lock
generated
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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" />
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue