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",
|
||||
"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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue