diff --git a/apps/desktop/src-tauri/Cargo.lock b/apps/desktop/src-tauri/Cargo.lock
index 49ef451..1927816 100644
--- a/apps/desktop/src-tauri/Cargo.lock
+++ b/apps/desktop/src-tauri/Cargo.lock
@@ -486,6 +486,7 @@ dependencies = [
"serde_json",
"serde_yaml",
"sha1",
+ "tar",
"tauri",
"tauri-build",
"tauri-plugin-dialog",
@@ -4978,6 +4979,17 @@ dependencies = [
"syn 2.0.117",
]
+[[package]]
+name = "tar"
+version = "0.4.44"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1d863878d212c87a19c1a610eb53bb01fe12951c0501cf5a0d65f724914a667a"
+dependencies = [
+ "filetime",
+ "libc",
+ "xattr",
+]
+
[[package]]
name = "target-lexicon"
version = "0.12.16"
@@ -7334,6 +7346,16 @@ dependencies = [
"pkg-config",
]
+[[package]]
+name = "xattr"
+version = "1.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156"
+dependencies = [
+ "libc",
+ "rustix 1.1.4",
+]
+
[[package]]
name = "xdg"
version = "2.5.2"
diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml
index 85923d3..906b63e 100644
--- a/apps/desktop/src-tauri/Cargo.toml
+++ b/apps/desktop/src-tauri/Cargo.toml
@@ -37,6 +37,7 @@ tectonic = { version = "0.15", default-features = true, features = ["external-ha
# Linux: apt install libicu-dev libgraphite2-dev libharfbuzz-dev libfreetype-dev libfontconfig-dev
# Windows: vcpkg install harfbuzz[graphite2] freetype icu fontconfig (with TECTONIC_DEP_BACKEND=vcpkg)
flate2 = "1"
+tar = "0.4"
[dev-dependencies]
tempfile = "3"
diff --git a/apps/desktop/src-tauri/src/claude.rs b/apps/desktop/src-tauri/src/claude.rs
index 34dca0e..f72f13d 100644
--- a/apps/desktop/src-tauri/src/claude.rs
+++ b/apps/desktop/src-tauri/src/claude.rs
@@ -150,20 +150,34 @@ fn create_command(program: &str, args: Vec, cwd: &str, effort_level: Opt
// Set effort level (default: low for fast responses)
cmd.env("CLAUDE_CODE_EFFORT_LEVEL", effort_level.unwrap_or("low"));
+ // Build PATH: start with current PATH, prepend program dir and venv bin
+ let mut current_path = std::env::var("PATH").unwrap_or_default();
+ #[cfg(target_os = "windows")]
+ let sep = ";";
+ #[cfg(not(target_os = "windows"))]
+ let sep = ":";
+
// Add the program's parent directory to PATH if not already present
if let Some(program_dir) = std::path::Path::new(program).parent() {
- let current_path = std::env::var("PATH").unwrap_or_default();
let program_dir_str = program_dir.to_string_lossy();
if !current_path.contains(program_dir_str.as_ref()) {
- #[cfg(target_os = "windows")]
- let sep = ";";
- #[cfg(not(target_os = "windows"))]
- let sep = ":";
- let new_path = format!("{}{}{}", program_dir_str, sep, current_path);
- cmd.env("PATH", new_path);
+ current_path = format!("{}{}{}", program_dir_str, sep, current_path);
}
}
+ // Auto-detect project venv and inject VIRTUAL_ENV + PATH
+ let venv_dir = std::path::Path::new(cwd).join(".venv");
+ if venv_dir.exists() {
+ cmd.env("VIRTUAL_ENV", &venv_dir);
+ #[cfg(not(target_os = "windows"))]
+ let venv_bin = venv_dir.join("bin");
+ #[cfg(target_os = "windows")]
+ let venv_bin = venv_dir.join("Scripts");
+ current_path = format!("{}{}{}", venv_bin.to_string_lossy(), sep, current_path);
+ }
+
+ cmd.env("PATH", current_path);
+
cmd
}
@@ -576,7 +590,11 @@ fn common_claude_args() -> Vec {
"4. PRESERVE EXISTING CONTENT: Always read the file first. Keep the existing preamble, packages, ",
"and structure intact. Only add or modify what is needed for the current step.\n",
"5. LaTeX BEST PRACTICES: Use proper sectioning (\\chapter, \\section, \\subsection), ",
- "citations (\\cite), cross-references (\\label, \\ref), and BibTeX for bibliographies."
+ "citations (\\cite), cross-references (\\label, \\ref), and BibTeX for bibliographies.\n",
+ "6. SKILLS: If scientific skills are installed in .claude/skills/, follow their guidelines ",
+ "for domain-specific tasks. Use skill-provided LaTeX packages (.sty) and code patterns.\n",
+ "7. PYTHON: If a .venv/ exists in the project, it is already activated. ",
+ "Use `uv pip install` to add packages and `python` to run scripts."
).to_string(),
]
}
diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs
index dd468d0..b31917d 100644
--- a/apps/desktop/src-tauri/src/lib.rs
+++ b/apps/desktop/src-tauri/src/lib.rs
@@ -1,7 +1,9 @@
mod claude;
mod history;
mod latex;
+mod skills;
mod slash_commands;
+mod uv;
mod zotero;
use std::path::Path;
@@ -185,6 +187,18 @@ pub fn run() {
slash_commands::slash_command_get,
slash_commands::slash_command_save,
slash_commands::slash_command_delete,
+ skills::install_scientific_skills,
+ skills::install_scientific_skills_global,
+ skills::check_skills_installed,
+ skills::list_installed_skills,
+ skills::uninstall_scientific_skills,
+ skills::get_skill_categories,
+ skills::get_skill_content,
+ uv::check_uv_status,
+ uv::install_uv,
+ uv::setup_project_venv,
+ uv::uv_add_packages,
+ uv::uv_run_command,
])
.build(tauri::generate_context!())
.expect("error while building tauri application");
diff --git a/apps/desktop/src-tauri/src/skills.rs b/apps/desktop/src-tauri/src/skills.rs
new file mode 100644
index 0000000..4c2b641
--- /dev/null
+++ b/apps/desktop/src-tauri/src/skills.rs
@@ -0,0 +1,761 @@
+use serde::{Deserialize, Serialize};
+use std::path::{Path, PathBuf};
+use tokio::process::Command;
+
+const REPO_URL: &str = "https://github.com/K-Dense-AI/claude-scientific-skills.git";
+const TARBALL_URL: &str =
+ "https://github.com/K-Dense-AI/claude-scientific-skills/archive/refs/heads/main.tar.gz";
+const SKILLS_SUBFOLDER: &str = "scientific-skills";
+
+// ─── Data Types ───
+
+#[derive(Debug, Serialize, Deserialize, Clone)]
+pub struct SkillInfo {
+ pub id: String,
+ pub name: String,
+ pub domain: String,
+ pub description: String,
+ pub folder: String,
+}
+
+#[derive(Debug, Serialize)]
+pub struct InstallResult {
+ pub success: bool,
+ pub skills_installed: usize,
+ pub target_dir: String,
+ pub message: String,
+}
+
+#[derive(Debug, Serialize)]
+pub struct SkillsStatus {
+ pub installed: bool,
+ pub skill_count: usize,
+ pub location: String,
+}
+
+#[derive(Debug, Serialize, Clone)]
+pub struct SkillEntry {
+ pub name: String,
+ pub folder: String,
+}
+
+#[derive(Debug, Serialize, Clone)]
+pub struct SkillCategory {
+ pub id: String,
+ pub name: String,
+ pub icon: String,
+ pub skill_count: usize,
+ pub skills: Vec,
+}
+
+// ─── Skill Categories Data ───
+
+/// Returns the known scientific skill categories with metadata.
+fn skill_categories() -> Vec {
+ fn s(name: &str, folder: &str) -> SkillEntry {
+ SkillEntry { name: name.into(), folder: folder.into() }
+ }
+
+ let mut cats = vec![
+ SkillCategory {
+ id: "bioinformatics".into(),
+ name: "Bioinformatics & Genomics".into(),
+ icon: "dna".into(),
+ skill_count: 0,
+ skills: vec![
+ s("Scanpy (scRNA-seq)", "scanpy"),
+ s("BioPython", "biopython"),
+ s("PyDESeq2", "pydeseq2"),
+ s("PySAM", "pysam"),
+ s("gget", "gget"),
+ s("scikit-bio", "scikit-bio"),
+ s("DeepTools", "deeptools"),
+ s("CELLxGENE Census", "cellxgene-census"),
+ s("AnnData", "anndata"),
+ s("GTARS", "gtars"),
+ s("ETE Toolkit", "etetoolkit"),
+ s("TileDB-VCF", "tiledbvcf"),
+ s("FlowIO", "flowio"),
+ s("GenIML", "geniml"),
+ s("Ensembl Database", "ensembl-database"),
+ s("Gene Database", "gene-database"),
+ ],
+ },
+ SkillCategory {
+ id: "cheminformatics".into(),
+ name: "Cheminformatics & Drug Discovery".into(),
+ icon: "flask-conical".into(),
+ skill_count: 0,
+ skills: vec![
+ s("RDKit", "rdkit"),
+ s("Datamol", "datamol"),
+ s("MolFeat", "molfeat"),
+ s("MedChem Filters", "medchem"),
+ s("DeepChem", "deepchem"),
+ s("PubChem Database", "pubchem-database"),
+ s("ChEMBL Database", "chembl-database"),
+ s("ZINC Database", "zinc-database"),
+ s("TorchDrug", "torchdrug"),
+ s("DiffDock", "diffdock"),
+ s("Rowan", "rowan"),
+ ],
+ },
+ SkillCategory {
+ id: "clinical".into(),
+ name: "Clinical Research".into(),
+ icon: "heart-pulse".into(),
+ skill_count: 0,
+ skills: vec![
+ s("ClinicalTrials.gov", "clinicaltrials-database"),
+ s("ClinVar Database", "clinvar-database"),
+ s("ClinPGx Database", "clinpgx-database"),
+ s("Treatment Plans", "treatment-plans"),
+ s("Clinical Reports", "clinical-reports"),
+ s("Clinical Decision Support", "clinical-decision-support"),
+ s("DrugBank Database", "drugbank-database"),
+ s("FDA Database", "fda-database"),
+ s("BRENDA Database", "brenda-database"),
+ s("PyTDC", "pytdc"),
+ s("ISO 13485 Certification", "iso-13485-certification"),
+ s("COSMIC Database", "cosmic-database"),
+ ],
+ },
+ SkillCategory {
+ id: "data-analysis".into(),
+ name: "Data Analysis & Visualization".into(),
+ icon: "bar-chart-3".into(),
+ skill_count: 0,
+ skills: vec![
+ s("Statistical Analysis", "statistical-analysis"),
+ s("Exploratory Data Analysis", "exploratory-data-analysis"),
+ s("Polars", "polars"),
+ s("Dask", "dask"),
+ s("Vaex", "vaex"),
+ s("NetworkX", "networkx"),
+ s("Seaborn", "seaborn"),
+ s("Plotly", "plotly"),
+ s("Matplotlib", "matplotlib"),
+ s("Scientific Visualization", "scientific-visualization"),
+ s("Zarr", "zarr-python"),
+ s("Data Commons", "datacommons-client"),
+ s("Aeon (Time Series ML)", "aeon"),
+ s("TimesFM Forecasting", "timesfm-forecasting"),
+ ],
+ },
+ SkillCategory {
+ id: "ml-ai".into(),
+ name: "Machine Learning & AI".into(),
+ icon: "brain".into(),
+ skill_count: 0,
+ skills: vec![
+ s("scikit-learn", "scikit-learn"),
+ s("Transformers", "transformers"),
+ s("PyTorch Lightning", "pytorch-lightning"),
+ s("PyG (Graph Neural Nets)", "torch_geometric"),
+ s("Stable Baselines3", "stable-baselines3"),
+ s("PufferLib", "pufferlib"),
+ s("SHAP", "shap"),
+ s("UMAP", "umap-learn"),
+ s("HypoGeniC", "hypogenic"),
+ s("Hypothesis Generation", "hypothesis-generation"),
+ s("Statsmodels", "statsmodels"),
+ s("PyMC", "pymc"),
+ s("PennyLane", "pennylane"),
+ s("Qiskit", "qiskit"),
+ s("Cirq", "cirq"),
+ ],
+ },
+ SkillCategory {
+ id: "scientific-communication".into(),
+ name: "Scientific Communication".into(),
+ icon: "book-open".into(),
+ skill_count: 0,
+ skills: vec![
+ s("Scientific Writing", "scientific-writing"),
+ s("Literature Review", "literature-review"),
+ s("Peer Review", "peer-review"),
+ s("Grant Writing", "research-grants"),
+ s("Citation Management", "citation-management"),
+ s("Scientific Slides", "scientific-slides"),
+ s("LaTeX Posters", "latex-posters"),
+ s("HTML/PPTX Posters", "pptx-posters"),
+ s("Infographics", "infographics"),
+ s("Scientific Schematics", "scientific-schematics"),
+ s("Markdown & Mermaid", "markdown-mermaid-writing"),
+ s("Scientific Brainstorming", "scientific-brainstorming"),
+ s("Critical Thinking", "scientific-critical-thinking"),
+ s("Scholar Evaluation", "scholar-evaluation"),
+ s("Paper to Web", "paper-2-web"),
+ s("Venue Templates", "venue-templates"),
+ s("Market Research Reports", "market-research-reports"),
+ s("Image Generation", "generate-image"),
+ s("Open Notebook", "open-notebook"),
+ s("MarkItDown", "markitdown"),
+ ],
+ },
+ SkillCategory {
+ id: "multi-omics".into(),
+ name: "Multi-omics & Systems Biology".into(),
+ icon: "microscope".into(),
+ skill_count: 0,
+ skills: vec![
+ s("scvi-tools", "scvi-tools"),
+ s("COBRApy", "cobrapy"),
+ s("Bioservices", "bioservices"),
+ s("Arboreto (GRN)", "arboreto"),
+ s("Reactome Database", "reactome-database"),
+ ],
+ },
+ SkillCategory {
+ id: "engineering".into(),
+ name: "Engineering & Simulation".into(),
+ icon: "settings".into(),
+ skill_count: 0,
+ skills: vec![
+ s("SimPy", "simpy"),
+ s("pymoo", "pymoo"),
+ s("FluidSim", "fluidsim"),
+ s("MATLAB/Octave", "matlab"),
+ ],
+ },
+ SkillCategory {
+ id: "proteomics".into(),
+ name: "Proteomics & Mass Spec".into(),
+ icon: "atom".into(),
+ skill_count: 0,
+ skills: vec![
+ s("PyOpenMS", "pyopenms"),
+ s("matchms", "matchms"),
+ s("ESM (Protein LM)", "esm"),
+ s("PDB Database", "pdb-database"),
+ s("UniProt Database", "uniprot-database"),
+ s("HMDB Database", "hmdb-database"),
+ ],
+ },
+ SkillCategory {
+ id: "healthcare-ai".into(),
+ name: "Healthcare AI & Clinical ML".into(),
+ icon: "activity".into(),
+ skill_count: 0,
+ skills: vec![
+ s("PyHealth", "pyhealth"),
+ s("NeuroKit2", "neurokit2"),
+ s("scikit-survival", "scikit-survival"),
+ s("GWAS Catalog", "gwas-database"),
+ s("OpenAlex Database", "openalex-database"),
+ s("PubMed Database", "pubmed-database"),
+ s("bioRxiv Database", "biorxiv-database"),
+ s("GEO Database", "geo-database"),
+ ],
+ },
+ SkillCategory {
+ id: "medical-imaging".into(),
+ name: "Medical Imaging".into(),
+ icon: "scan".into(),
+ skill_count: 0,
+ skills: vec![
+ s("pydicom", "pydicom"),
+ s("HistoLab", "histolab"),
+ s("PathML", "pathml"),
+ s("Neuropixels Analysis", "neuropixels-analysis"),
+ s("Imaging Data Commons", "imaging-data-commons"),
+ s("GeoMaster", "geomaster"),
+ s("GeoPandas", "geopandas"),
+ ],
+ },
+ SkillCategory {
+ id: "materials-science".into(),
+ name: "Materials Science".into(),
+ icon: "gem".into(),
+ skill_count: 0,
+ skills: vec![
+ s("Pymatgen", "pymatgen"),
+ s("QuTiP", "qutip"),
+ s("SymPy", "sympy"),
+ s("Astropy", "astropy"),
+ s("Open Targets", "opentargets-database"),
+ ],
+ },
+ SkillCategory {
+ id: "physics-astronomy".into(),
+ name: "Physics & Astronomy".into(),
+ icon: "telescope".into(),
+ skill_count: 0,
+ skills: vec![
+ s("Astropy", "astropy"),
+ s("QuTiP", "qutip"),
+ s("PennyLane", "pennylane"),
+ s("SymPy", "sympy"),
+ ],
+ },
+ SkillCategory {
+ id: "lab-automation".into(),
+ name: "Laboratory Automation".into(),
+ icon: "pipette".into(),
+ skill_count: 0,
+ skills: vec![
+ s("Opentrons", "opentrons-integration"),
+ s("PyLabRobot", "pylabrobot"),
+ s("Protocols.io", "protocolsio-integration"),
+ s("LabArchive", "labarchive-integration"),
+ s("Ginkgo Cloud Lab", "ginkgo-cloud-lab"),
+ ],
+ },
+ SkillCategory {
+ id: "protein-engineering".into(),
+ name: "Protein Engineering".into(),
+ icon: "helix".into(),
+ skill_count: 0,
+ skills: vec![
+ s("AlphaFold Database", "alphafold-database"),
+ s("ESM (Protein LM)", "esm"),
+ s("DiffDock", "diffdock"),
+ s("Adaptyv", "adaptyv"),
+ s("STRING Database", "string-database"),
+ s("LaminDB", "lamindb"),
+ ],
+ },
+ SkillCategory {
+ id: "research-methodology".into(),
+ name: "Research Methodology".into(),
+ icon: "lightbulb".into(),
+ skill_count: 0,
+ skills: vec![
+ s("Hypothesis Generation", "hypothesis-generation"),
+ s("Scientific Brainstorming", "scientific-brainstorming"),
+ s("Critical Thinking", "scientific-critical-thinking"),
+ s("Experimental Design", "hypothesis-generation"),
+ s("Scholar Evaluation", "scholar-evaluation"),
+ s("Peer Review", "peer-review"),
+ s("Research Lookup", "research-lookup"),
+ s("Denario", "denario"),
+ s("bGPT Paper Search", "bgpt-paper-search"),
+ s("Perplexity Search", "perplexity-search"),
+ ],
+ },
+ ];
+
+ for cat in &mut cats {
+ cat.skill_count = cat.skills.len();
+ }
+
+ cats
+}
+
+// ─── Helpers ───
+
+/// Resolve the target skills directory.
+fn skills_dir(project_path: Option<&str>) -> PathBuf {
+ match project_path {
+ Some(p) => PathBuf::from(p).join(".claude").join("skills"),
+ None => dirs::home_dir()
+ .unwrap_or_else(|| PathBuf::from("."))
+ .join(".claude")
+ .join("skills"),
+ }
+}
+
+/// Check if git is available on PATH.
+async fn git_available() -> bool {
+ Command::new("git")
+ .arg("--version")
+ .output()
+ .await
+ .map(|o| o.status.success())
+ .unwrap_or(false)
+}
+
+/// Clone the repo using git (shallow clone).
+async fn clone_repo(tmp_dir: &Path) -> Result<(), String> {
+ let output = Command::new("git")
+ .args(["clone", "--depth", "1", REPO_URL])
+ .arg(tmp_dir.join("repo"))
+ .output()
+ .await
+ .map_err(|e| format!("Failed to run git clone: {}", e))?;
+
+ if !output.status.success() {
+ let stderr = String::from_utf8_lossy(&output.stderr);
+ return Err(format!("git clone failed: {}", stderr));
+ }
+ Ok(())
+}
+
+/// Download and extract tarball as fallback when git is not available.
+async fn download_tarball(tmp_dir: &Path) -> Result<(), String> {
+ let response = reqwest::get(TARBALL_URL)
+ .await
+ .map_err(|e| format!("Failed to download tarball: {}", e))?;
+
+ if !response.status().is_success() {
+ return Err(format!(
+ "Tarball download failed with status: {}",
+ response.status()
+ ));
+ }
+
+ let bytes = response
+ .bytes()
+ .await
+ .map_err(|e| format!("Failed to read tarball bytes: {}", e))?;
+
+ // Decompress gzip
+ let decoder = flate2::read::GzDecoder::new(&bytes[..]);
+ let mut archive = tar::Archive::new(decoder);
+
+ archive
+ .unpack(tmp_dir.join("repo-raw"))
+ .map_err(|e| format!("Failed to extract tarball: {}", e))?;
+
+ // The tarball extracts to claude-scientific-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))?;
+ }
+ }
+
+ // Clean up the raw extraction directory
+ let _ = std::fs::remove_dir_all(&raw_dir);
+
+ Ok(())
+}
+
+/// Copy the scientific-skills directory from the cloned repo to the target.
+fn copy_skills(repo_dir: &Path, target_dir: &Path) -> Result {
+ let src = repo_dir.join(SKILLS_SUBFOLDER);
+ if !src.exists() {
+ return Err(format!(
+ "scientific-skills directory not found in cloned repo at {}",
+ src.display()
+ ));
+ }
+
+ // Create target directory
+ std::fs::create_dir_all(target_dir)
+ .map_err(|e| format!("Failed to create target dir: {}", e))?;
+
+ let mut count = 0;
+
+ // Iterate through skill subdirectories
+ let entries = std::fs::read_dir(&src)
+ .map_err(|e| format!("Failed to read skills dir: {}", e))?;
+
+ for entry in entries.flatten() {
+ let entry_path = entry.path();
+ if !entry_path.is_dir() {
+ continue;
+ }
+
+ let skill_name = entry
+ .file_name()
+ .to_string_lossy()
+ .to_string();
+
+ let target_skill = target_dir.join(&skill_name);
+ copy_dir_recursive(&entry_path, &target_skill)?;
+ count += 1;
+ }
+
+ Ok(count)
+}
+
+/// Recursively copy a directory.
+fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<(), String> {
+ std::fs::create_dir_all(dst)
+ .map_err(|e| format!("Failed to create dir {}: {}", dst.display(), e))?;
+
+ let entries = std::fs::read_dir(src)
+ .map_err(|e| format!("Failed to read dir {}: {}", src.display(), e))?;
+
+ for entry in entries.flatten() {
+ let entry_path = entry.path();
+ let target = dst.join(entry.file_name());
+
+ if entry_path.is_dir() {
+ copy_dir_recursive(&entry_path, &target)?;
+ } else {
+ std::fs::copy(&entry_path, &target)
+ .map_err(|e| format!("Failed to copy {}: {}", entry_path.display(), e))?;
+ }
+ }
+
+ Ok(())
+}
+
+/// Parse a SKILL.md file to extract skill info.
+fn parse_skill_md(skill_dir: &Path) -> Option {
+ let skill_md = skill_dir.join("SKILL.md");
+ if !skill_md.exists() {
+ return None;
+ }
+
+ let content = std::fs::read_to_string(&skill_md).ok()?;
+ let folder = skill_dir
+ .file_name()?
+ .to_string_lossy()
+ .to_string();
+
+ // Extract title from first # heading
+ let name = content
+ .lines()
+ .find(|l| l.starts_with("# "))
+ .map(|l| l.trim_start_matches("# ").trim().to_string())
+ .unwrap_or_else(|| folder.clone());
+
+ // Extract description from first paragraph after heading
+ let description = content
+ .lines()
+ .skip_while(|l| !l.starts_with("# "))
+ .skip(1)
+ .skip_while(|l| l.trim().is_empty())
+ .take_while(|l| !l.trim().is_empty() && !l.starts_with('#'))
+ .collect::>()
+ .join(" ")
+ .chars()
+ .take(200)
+ .collect::();
+
+ // Infer domain from folder name prefix (e.g., "bioinformatics-rna-seq" → "bioinformatics")
+ let domain = folder
+ .split('-')
+ .next()
+ .unwrap_or("general")
+ .to_string();
+
+ Some(SkillInfo {
+ id: folder.clone(),
+ name,
+ domain,
+ description,
+ folder,
+ })
+}
+
+// ─── Tauri Commands ───
+
+#[tauri::command]
+pub async fn install_scientific_skills(project_path: String) -> Result {
+ let target = skills_dir(Some(&project_path));
+ install_skills_to(&target, Some(&project_path)).await
+}
+
+#[tauri::command]
+pub async fn install_scientific_skills_global() -> Result {
+ let target = skills_dir(None);
+ install_skills_to(&target, None).await
+}
+
+/// Core installation logic.
+async fn install_skills_to(
+ target: &Path,
+ _project_path: Option<&str>,
+) -> Result {
+ // Create a temporary directory for the clone/download
+ let tmp_dir = std::env::temp_dir().join(format!(
+ "claude-scientific-skills-{}",
+ std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .unwrap_or_default()
+ .as_millis()
+ ));
+ std::fs::create_dir_all(&tmp_dir)
+ .map_err(|e| format!("Failed to create temp dir: {}", e))?;
+
+ // Try git clone first, fall back to tarball download
+ if git_available().await {
+ if let Err(e) = clone_repo(&tmp_dir).await {
+ eprintln!("git clone failed, trying tarball: {}", e);
+ download_tarball(&tmp_dir).await?;
+ }
+ } else {
+ download_tarball(&tmp_dir).await?;
+ }
+
+ let repo_dir = tmp_dir.join("repo");
+
+ // Copy skills to target directory
+ let count = copy_skills(&repo_dir, target)?;
+
+ // Clean up temp directory
+ let _ = std::fs::remove_dir_all(&tmp_dir);
+
+ 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
+ ),
+ })
+}
+
+#[tauri::command]
+pub async fn check_skills_installed(
+ project_path: Option,
+) -> Result {
+ let target = skills_dir(project_path.as_deref());
+
+ if !target.exists() {
+ return Ok(SkillsStatus {
+ installed: false,
+ skill_count: 0,
+ location: target.to_string_lossy().to_string(),
+ });
+ }
+
+ // Count subdirectories that contain SKILL.md
+ let count = std::fs::read_dir(&target)
+ .map_err(|e| format!("Failed to read skills dir: {}", e))?
+ .flatten()
+ .filter(|e| e.path().is_dir() && e.path().join("SKILL.md").exists())
+ .count();
+
+ Ok(SkillsStatus {
+ installed: count > 0,
+ skill_count: count,
+ location: target.to_string_lossy().to_string(),
+ })
+}
+
+#[tauri::command]
+pub async fn list_installed_skills(
+ project_path: Option,
+) -> Result, String> {
+ let target = skills_dir(project_path.as_deref());
+
+ if !target.exists() {
+ return Ok(Vec::new());
+ }
+
+ let mut skills = Vec::new();
+ let entries = std::fs::read_dir(&target)
+ .map_err(|e| format!("Failed to read skills dir: {}", e))?;
+
+ for entry in entries.flatten() {
+ if entry.path().is_dir() {
+ if let Some(info) = parse_skill_md(&entry.path()) {
+ skills.push(info);
+ }
+ }
+ }
+
+ skills.sort_by(|a, b| a.name.cmp(&b.name));
+ Ok(skills)
+}
+
+#[tauri::command]
+pub async fn uninstall_scientific_skills(
+ project_path: Option,
+) -> Result<(), String> {
+ let target = skills_dir(project_path.as_deref());
+
+ if target.exists() {
+ std::fs::remove_dir_all(&target)
+ .map_err(|e| format!("Failed to remove skills: {}", e))?;
+ }
+
+ Ok(())
+}
+
+#[tauri::command]
+pub fn get_skill_categories() -> Vec {
+ skill_categories()
+}
+
+/// Read the raw SKILL.md content for a specific skill folder.
+/// Tries local install first, then fetches from GitHub.
+#[tauri::command]
+pub async fn get_skill_content(
+ skill_folder: String,
+ project_path: Option,
+) -> Result {
+ // Try local (project-level first, then global)
+ let locations: Vec = match project_path.as_deref() {
+ Some(pp) => vec![skills_dir(Some(pp)), skills_dir(None)],
+ None => vec![skills_dir(None)],
+ };
+
+ for base in &locations {
+ let skill_md = base.join(&skill_folder).join("SKILL.md");
+ if skill_md.exists() {
+ return std::fs::read_to_string(&skill_md)
+ .map_err(|e| format!("Failed to read SKILL.md: {}", e));
+ }
+ }
+
+ // Fallback: fetch from GitHub
+ let url = format!(
+ "https://raw.githubusercontent.com/K-Dense-AI/claude-scientific-skills/main/scientific-skills/{}/SKILL.md",
+ skill_folder
+ );
+
+ let response = reqwest::get(&url)
+ .await
+ .map_err(|e| format!("Failed to fetch from GitHub: {}", e))?;
+
+ if !response.status().is_success() {
+ return Err(format!("Skill '{}' not found (HTTP {})", skill_folder, response.status()));
+ }
+
+ response
+ .text()
+ .await
+ .map_err(|e| format!("Failed to read response: {}", e))
+}
+
+// ─── Tests ───
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn test_skills_dir_global() {
+ let dir = skills_dir(None);
+ assert!(dir.to_string_lossy().contains(".claude"));
+ assert!(dir.to_string_lossy().ends_with("skills"));
+ }
+
+ #[test]
+ fn test_skills_dir_project() {
+ let dir = skills_dir(Some("/tmp/my-project"));
+ assert_eq!(
+ dir,
+ PathBuf::from("/tmp/my-project/.claude/skills")
+ );
+ }
+
+ #[test]
+ fn test_skill_categories_count() {
+ let cats = skill_categories();
+ assert_eq!(cats.len(), 16);
+ // Verify skill_count matches actual skills vec length
+ for cat in &cats {
+ assert_eq!(cat.skill_count, cat.skills.len(), "Mismatch in {}", cat.id);
+ }
+ let total: usize = cats.iter().map(|c| c.skill_count).sum();
+ assert!(total >= 100);
+ }
+
+ #[test]
+ fn test_parse_skill_md() {
+ let tmp = std::env::temp_dir().join("test-skill-parse");
+ let _ = std::fs::remove_dir_all(&tmp);
+ std::fs::create_dir_all(&tmp).unwrap();
+
+ let skill_content = "# RNA-seq Analysis\n\nComprehensive RNA-seq data analysis pipeline.\n\n## Usage\nUse this skill for RNA sequencing workflows.\n";
+ std::fs::write(tmp.join("SKILL.md"), skill_content).unwrap();
+
+ let info = parse_skill_md(&tmp).unwrap();
+ assert_eq!(info.name, "RNA-seq Analysis");
+ assert!(info.description.contains("RNA-seq"));
+
+ let _ = std::fs::remove_dir_all(&tmp);
+ }
+}
diff --git a/apps/desktop/src-tauri/src/uv.rs b/apps/desktop/src-tauri/src/uv.rs
new file mode 100644
index 0000000..5eb419c
--- /dev/null
+++ b/apps/desktop/src-tauri/src/uv.rs
@@ -0,0 +1,350 @@
+use std::path::PathBuf;
+use tauri::{Emitter, WebviewWindow};
+use tokio::io::{AsyncBufReadExt, BufReader};
+
+// ─── Binary Discovery ───
+
+/// Discover the uv binary on the system.
+/// Checks: which → cargo bin → standard paths → bare fallback.
+fn find_uv_binary() -> Result {
+ // 1. Try to find uv on PATH
+ if let Ok(path) = which::which("uv") {
+ return Ok(path.to_string_lossy().to_string());
+ }
+
+ // 2. Check user-specific paths
+ if let Some(home) = dirs::home_dir() {
+ #[cfg(not(target_os = "windows"))]
+ let user_paths = vec![
+ home.join(".cargo").join("bin").join("uv"),
+ home.join(".local").join("bin").join("uv"),
+ ];
+ #[cfg(target_os = "windows")]
+ let user_paths = vec![
+ home.join(".cargo").join("bin").join("uv.exe"),
+ // uv's default Windows install location
+ PathBuf::from(
+ std::env::var("LOCALAPPDATA").unwrap_or_else(|_| {
+ home.join("AppData").join("Local").to_string_lossy().to_string()
+ }),
+ )
+ .join("uv")
+ .join("bin")
+ .join("uv.exe"),
+ ];
+
+ for path in &user_paths {
+ if path.exists() {
+ return Ok(path.to_string_lossy().to_string());
+ }
+ }
+ }
+
+ // 3. Check standard paths (Unix only)
+ #[cfg(not(target_os = "windows"))]
+ {
+ let standard_paths = [
+ "/usr/local/bin/uv",
+ "/opt/homebrew/bin/uv",
+ "/usr/bin/uv",
+ ];
+ for path in &standard_paths {
+ if PathBuf::from(path).exists() {
+ return Ok(path.to_string());
+ }
+ }
+ }
+
+ // 4. Bare fallback — hope it's in PATH
+ Ok("uv".to_string())
+}
+
+// ─── Status Types ───
+
+#[derive(serde::Serialize)]
+pub struct UvStatus {
+ pub installed: bool,
+ pub binary_path: Option,
+ pub version: Option,
+}
+
+#[derive(serde::Serialize)]
+pub struct VenvInfo {
+ pub venv_path: String,
+ pub python_path: String,
+ pub created: bool,
+}
+
+#[derive(serde::Serialize)]
+pub struct UvCommandResult {
+ pub stdout: String,
+ pub stderr: String,
+ pub exit_code: i32,
+}
+
+// ─── Helper: build PATH with venv bin prepended ───
+
+fn venv_bin_dir(venv_dir: &std::path::Path) -> PathBuf {
+ #[cfg(not(target_os = "windows"))]
+ {
+ venv_dir.join("bin")
+ }
+ #[cfg(target_os = "windows")]
+ {
+ venv_dir.join("Scripts")
+ }
+}
+
+fn venv_python(venv_dir: &std::path::Path) -> PathBuf {
+ #[cfg(not(target_os = "windows"))]
+ {
+ venv_bin_dir(venv_dir).join("python")
+ }
+ #[cfg(target_os = "windows")]
+ {
+ venv_bin_dir(venv_dir).join("python.exe")
+ }
+}
+
+fn path_with_venv(venv_dir: &std::path::Path) -> String {
+ let bin = venv_bin_dir(venv_dir);
+ let current = std::env::var("PATH").unwrap_or_default();
+ #[cfg(target_os = "windows")]
+ let sep = ";";
+ #[cfg(not(target_os = "windows"))]
+ let sep = ":";
+ format!("{}{}{}", bin.to_string_lossy(), sep, current)
+}
+
+// ─── Tauri Commands ───
+
+#[tauri::command]
+pub async fn check_uv_status() -> Result {
+ let binary_path = match find_uv_binary() {
+ Ok(path) => path,
+ Err(_) => {
+ return Ok(UvStatus {
+ installed: false,
+ binary_path: None,
+ version: None,
+ });
+ }
+ };
+
+ // Verify binary actually works by running --version
+ let version_output = std::process::Command::new(&binary_path)
+ .arg("--version")
+ .output();
+
+ let version = match version_output {
+ Ok(output) if output.status.success() => {
+ Some(String::from_utf8_lossy(&output.stdout).trim().to_string())
+ }
+ _ => {
+ return Ok(UvStatus {
+ installed: false,
+ binary_path: None,
+ version: None,
+ });
+ }
+ };
+
+ Ok(UvStatus {
+ installed: true,
+ binary_path: Some(binary_path),
+ version,
+ })
+}
+
+#[tauri::command]
+pub async fn install_uv(window: WebviewWindow) -> Result<(), String> {
+ #[cfg(not(target_os = "windows"))]
+ let mut cmd = {
+ let mut c = tokio::process::Command::new("bash");
+ c.args(["-c", "curl -LsSf https://astral.sh/uv/install.sh | sh"]);
+ c
+ };
+ #[cfg(target_os = "windows")]
+ let mut cmd = {
+ let mut c = tokio::process::Command::new("powershell");
+ c.args([
+ "-NoProfile",
+ "-Command",
+ "irm https://astral.sh/uv/install.ps1 | iex",
+ ]);
+ c
+ };
+
+ cmd.stdout(std::process::Stdio::piped());
+ cmd.stderr(std::process::Stdio::piped());
+
+ // Inherit essential environment variables
+ for (key, value) in std::env::vars() {
+ if key == "PATH"
+ || key == "HOME"
+ || key == "USER"
+ || key == "SHELL"
+ || key == "LANG"
+ || key.starts_with("LC_")
+ || key == "HOMEBREW_PREFIX"
+ || key == "HOMEBREW_CELLAR"
+ || key == "HTTP_PROXY"
+ || key == "HTTPS_PROXY"
+ || key == "NO_PROXY"
+ || key == "ALL_PROXY"
+ {
+ cmd.env(&key, &value);
+ }
+ }
+
+ let mut child = cmd
+ .spawn()
+ .map_err(|e| format!("Failed to run uv installer: {}", e))?;
+
+ let stdout = child.stdout.take().ok_or("Failed to capture stdout")?;
+ let stderr = child.stderr.take().ok_or("Failed to capture stderr")?;
+
+ let stdout_reader = BufReader::new(stdout);
+ let stderr_reader = BufReader::new(stderr);
+
+ // Stream stdout
+ let win_stdout = window.clone();
+ let stdout_task = tokio::spawn(async move {
+ let mut lines = stdout_reader.lines();
+ while let Ok(Some(line)) = lines.next_line().await {
+ let _ = win_stdout.emit("uv-install-output", &line);
+ }
+ });
+
+ // Stream stderr
+ let win_stderr = window.clone();
+ let stderr_task = tokio::spawn(async move {
+ let mut lines = stderr_reader.lines();
+ while let Ok(Some(line)) = lines.next_line().await {
+ let _ = win_stderr.emit("uv-install-output", &line);
+ }
+ });
+
+ // Wait for completion
+ let win_complete = window;
+ tokio::spawn(async move {
+ let _ = stdout_task.await;
+ let _ = stderr_task.await;
+
+ let success = match child.wait().await {
+ Ok(status) => status.success(),
+ Err(_) => false,
+ };
+
+ let _ = win_complete.emit("uv-install-complete", success);
+ });
+
+ Ok(())
+}
+
+#[tauri::command]
+pub async fn setup_project_venv(project_path: String) -> Result {
+ let project = std::path::Path::new(&project_path);
+ let venv_dir = project.join(".venv");
+
+ // If venv already exists, just return info
+ if venv_dir.exists() {
+ let python = venv_python(&venv_dir);
+ return Ok(VenvInfo {
+ venv_path: venv_dir.to_string_lossy().to_string(),
+ python_path: python.to_string_lossy().to_string(),
+ created: false,
+ });
+ }
+
+ let uv_bin = find_uv_binary().map_err(|e| format!("uv not found: {}", e))?;
+
+ // Create venv: uv venv /.venv
+ let output = tokio::process::Command::new(&uv_bin)
+ .args(["venv", &venv_dir.to_string_lossy()])
+ .current_dir(project)
+ .output()
+ .await
+ .map_err(|e| format!("Failed to create venv: {}", e))?;
+
+ if !output.status.success() {
+ let stderr = String::from_utf8_lossy(&output.stderr);
+ return Err(format!("uv venv failed: {}", stderr));
+ }
+
+ let python = venv_python(&venv_dir);
+
+ Ok(VenvInfo {
+ venv_path: venv_dir.to_string_lossy().to_string(),
+ python_path: python.to_string_lossy().to_string(),
+ created: true,
+ })
+}
+
+#[tauri::command]
+pub async fn uv_add_packages(
+ packages: Vec,
+ project_path: String,
+) -> Result {
+ let uv_bin = find_uv_binary().map_err(|e| format!("uv not found: {}", e))?;
+ let venv_dir = std::path::Path::new(&project_path).join(".venv");
+
+ if !venv_dir.exists() {
+ return Err("No .venv found. Run setup_project_venv first.".to_string());
+ }
+
+ let mut args = vec!["pip".to_string(), "install".to_string()];
+ args.extend(packages);
+
+ let output = tokio::process::Command::new(&uv_bin)
+ .args(&args)
+ .current_dir(&project_path)
+ .env("VIRTUAL_ENV", &venv_dir)
+ .env("PATH", path_with_venv(&venv_dir))
+ .output()
+ .await
+ .map_err(|e| format!("Failed to run uv pip install: {}", e))?;
+
+ if !output.status.success() {
+ let stderr = String::from_utf8_lossy(&output.stderr);
+ return Err(format!("uv pip install failed: {}", stderr));
+ }
+
+ let stdout = String::from_utf8_lossy(&output.stdout).to_string();
+ Ok(stdout)
+}
+
+#[tauri::command]
+pub async fn uv_run_command(
+ command: String,
+ project_path: String,
+) -> Result {
+ let venv_dir = std::path::Path::new(&project_path).join(".venv");
+
+ if !venv_dir.exists() {
+ return Err("No .venv found. Run setup_project_venv first.".to_string());
+ }
+
+ // Split command into program + args
+ let parts: Vec<&str> = command.split_whitespace().collect();
+ if parts.is_empty() {
+ return Err("Empty command".to_string());
+ }
+
+ let output = tokio::process::Command::new(parts[0])
+ .args(&parts[1..])
+ .current_dir(&project_path)
+ .env("VIRTUAL_ENV", &venv_dir)
+ .env("PATH", path_with_venv(&venv_dir))
+ .output()
+ .await
+ .map_err(|e| format!("Failed to run command: {}", e))?;
+
+ let exit_code = output.status.code().unwrap_or(-1);
+
+ Ok(UvCommandResult {
+ stdout: String::from_utf8_lossy(&output.stdout).to_string(),
+ stderr: String::from_utf8_lossy(&output.stderr).to_string(),
+ exit_code,
+ })
+}
diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx
index e7e53b6..6b9db03 100644
--- a/apps/desktop/src/App.tsx
+++ b/apps/desktop/src/App.tsx
@@ -5,13 +5,19 @@ import { useDocumentStore } from "@/stores/document-store";
import { useClaudeChatStore } from "@/stores/claude-chat-store";
import { ProjectPicker } from "@/components/project-picker";
import { WorkspaceLayout } from "@/components/workspace/workspace-layout";
-import { useEffect } from "react";
+import { useEffect, useState } from "react";
import { getCurrentWindow } from "@tauri-apps/api/window";
import { TooltipProvider } from "@/components/ui/tooltip";
+import {
+ ScientificSkillsOnboarding,
+ shouldShowOnboarding,
+} from "@/components/scientific-skills/scientific-skills-onboarding";
+import { useUvSetupStore } from "@/stores/uv-setup-store";
function WorkspaceWithClaude() {
const projectRoot = useDocumentStore((s) => s.projectRoot);
const initialized = useDocumentStore((s) => s.initialized);
+ const [showSkillsOnboarding, setShowSkillsOnboarding] = useState(false);
// Update window title
useEffect(() => {
@@ -21,6 +27,28 @@ function WorkspaceWithClaude() {
}
}, [projectRoot]);
+ // Show scientific skills onboarding on first launch
+ useEffect(() => {
+ if (!initialized) return;
+ if (shouldShowOnboarding()) {
+ // Small delay so the workspace renders first
+ const timer = setTimeout(() => setShowSkillsOnboarding(true), 800);
+ return () => clearTimeout(timer);
+ }
+ }, [initialized]);
+
+ // Auto-setup Python venv when project opens
+ useEffect(() => {
+ if (!initialized || !projectRoot) return;
+ const uvStore = useUvSetupStore.getState();
+ uvStore.checkStatus().then(() => {
+ const { status } = useUvSetupStore.getState();
+ if (status === "ready") {
+ uvStore.setupVenv(projectRoot);
+ }
+ });
+ }, [initialized, projectRoot]);
+
// Consume pending initial prompt from project wizard
useEffect(() => {
if (!initialized) return;
@@ -38,6 +66,11 @@ function WorkspaceWithClaude() {
<>
+ {showSkillsOnboarding && (
+ setShowSkillsOnboarding(false)}
+ />
+ )}
>
);
}
diff --git a/apps/desktop/src/components/project-picker.tsx b/apps/desktop/src/components/project-picker.tsx
index 8360a83..374c0b0 100644
--- a/apps/desktop/src/components/project-picker.tsx
+++ b/apps/desktop/src/components/project-picker.tsx
@@ -1,4 +1,6 @@
-import { useEffect, useState } from "react";
+import { useCallback, useEffect, useState } from "react";
+import { invoke } from "@tauri-apps/api/core";
+import { listen } from "@tauri-apps/api/event";
import { open } from "@tauri-apps/plugin-dialog";
import {
FolderOpenIcon,
@@ -7,10 +9,17 @@ import {
XIcon,
FileTextIcon,
SparklesIcon,
+ CheckCircle2Icon,
+ CircleIcon,
+ TerminalIcon,
+ FlaskConicalIcon,
+ DownloadIcon,
+ Loader2Icon,
} from "lucide-react";
import { useProjectStore } from "@/stores/project-store";
import { useDocumentStore } from "@/stores/document-store";
import { useClaudeSetupStore } from "@/stores/claude-setup-store";
+import { useUvSetupStore } from "@/stores/uv-setup-store";
import { Button } from "@/components/ui/button";
import {
Dialog,
@@ -21,6 +30,7 @@ import {
} from "@/components/ui/dialog";
import { ProjectWizard, type CreationMode } from "./project-wizard";
import { ClaudeSetup } from "./claude-setup";
+import { cn } from "@/lib/utils";
export function ProjectPicker() {
const [showModeDialog, setShowModeDialog] = useState(false);
@@ -81,7 +91,7 @@ export function ProjectPicker() {
- {!isClaudeReady && }
+ {!isClaudeReady ? : }