//! Built-in expert skills management. //! //! Experts are curated skills (from obra/superpowers) that codeg bundles //! into its binary via `include_dir!`. On startup they are extracted to a //! central directory `~/.codeg/skills//`. Users can then enable an //! expert for any ACP agent by creating a symbolic link (or Windows //! junction) from the agent's skill directory into the central copy. //! //! The central store is the single source of truth. Enabling/disabling is //! purely "does a link exist in the agent's skill dir" — there is no //! database state, and updates propagate automatically when codeg upgrades //! and re-extracts the bundled files. use std::collections::BTreeMap; use std::fs; use std::io; use std::path::{Path, PathBuf}; use std::sync::OnceLock; use chrono::Utc; use include_dir::{include_dir, Dir, DirEntry}; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use tokio::sync::Mutex; use crate::commands::acp::{ preferred_scope_skill_dir, remove_skill_entry, scoped_skill_dirs, skill_storage_spec, validate_skill_id, }; use crate::acp::types::AgentSkillScope; use crate::models::agent::AgentType; // ─── Embedded bundle ──────────────────────────────────────────────────── static EXPERTS_BUNDLE: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/experts"); const CENTRAL_DIR_NAME: &str = ".codeg"; const CENTRAL_SKILLS_SUBDIR: &str = "skills"; const MANIFEST_FILE: &str = ".manifest.json"; const EXPERTS_TOML: &str = "experts.toml"; // ─── Error type ───────────────────────────────────────────────────────── #[derive(Debug, thiserror::Error)] pub enum ExpertsError { #[error("expert not found: {0}")] NotFound(String), #[error("agent does not support skills: {0:?}")] UnsupportedAgent(AgentType), #[error("a real directory already exists at '{path}' — delete or rename it first")] NameCollision { path: String }, #[error("a different link already exists at '{path}' (points to '{found}') — remove it first")] ForeignLink { path: String, found: String }, #[error("io error: {0}")] Io(String), #[error("metadata error: {0}")] Metadata(String), #[error("central expert store is unavailable: {0}")] CentralUnavailable(String), } impl Serialize for ExpertsError { fn serialize(&self, serializer: S) -> Result where S: serde::Serializer, { serializer.serialize_str(&self.to_string()) } } impl From for ExpertsError { fn from(err: io::Error) -> Self { ExpertsError::Io(err.to_string()) } } // ─── Public types ─────────────────────────────────────────────────────── #[derive(Debug, Clone, Serialize)] pub struct ExpertMetadata { pub id: String, pub category: String, pub icon: Option, pub sort_order: i32, pub display_name: BTreeMap, pub description: BTreeMap, pub bundled_hash: String, } #[derive(Debug, Clone, Serialize)] pub struct ExpertListItem { pub metadata: ExpertMetadata, pub installed_centrally: bool, pub user_modified: bool, pub central_path: String, } #[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum ExpertLinkState { NotLinked, LinkedToCodeg, LinkedElsewhere, BlockedByRealDirectory, Broken, } #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct ExpertInstallStatus { pub expert_id: String, pub agent_type: AgentType, pub state: ExpertLinkState, pub link_path: String, pub target_path: Option, pub expected_target_path: String, pub copy_mode: bool, } #[derive(Debug, Clone, Default, Serialize)] pub struct InstallReport { pub installed_count: usize, pub updated_count: usize, pub pending_user_review: Vec, pub errors: Vec, } // ─── Manifest ─────────────────────────────────────────────────────────── #[derive(Debug, Clone, Default, Serialize, Deserialize)] struct Manifest { #[serde(default)] codeg_version: String, #[serde(default)] installed_at: String, #[serde(default)] experts: BTreeMap, } #[derive(Debug, Clone, Default, Serialize, Deserialize)] struct ManifestEntry { #[serde(default)] hash: String, #[serde(default)] installed_at: String, #[serde(default)] pending_user_review: bool, } // ─── Concurrency ──────────────────────────────────────────────────────── fn mutation_lock() -> &'static Mutex<()> { static LOCK: OnceLock> = OnceLock::new(); LOCK.get_or_init(|| Mutex::new(())) } // ─── Paths ────────────────────────────────────────────────────────────── fn home_dir_or_default() -> PathBuf { dirs::home_dir().unwrap_or_else(|| PathBuf::from(".")) } pub(crate) fn central_experts_dir() -> PathBuf { home_dir_or_default() .join(CENTRAL_DIR_NAME) .join(CENTRAL_SKILLS_SUBDIR) } fn manifest_path() -> PathBuf { central_experts_dir().join(MANIFEST_FILE) } fn expert_central_path(expert_id: &str) -> PathBuf { central_experts_dir().join(expert_id) } fn agent_link_path(agent: AgentType, expert_id: &str) -> Result { let dir = preferred_scope_skill_dir(agent, AgentSkillScope::Global, None) .map_err(|_| ExpertsError::UnsupportedAgent(agent))?; Ok(dir.join(expert_id)) } // ─── Metadata loading ─────────────────────────────────────────────────── #[derive(Debug, Deserialize)] struct ExpertsTomlRoot { #[serde(default)] expert: Vec, } #[derive(Debug, Deserialize)] struct ExpertTomlEntry { id: String, category: String, #[serde(default)] icon: Option, #[serde(default)] sort_order: i32, #[serde(default)] display_name: BTreeMap, #[serde(default)] description: BTreeMap, } fn bundled_metadata() -> &'static [ExpertMetadata] { static METADATA: OnceLock> = OnceLock::new(); METADATA.get_or_init(|| match load_bundled_metadata_inner() { Ok(list) => list, Err(err) => { eprintln!("[Experts] failed to load bundled metadata: {err}"); Vec::new() } }) } fn load_bundled_metadata_inner() -> Result, ExpertsError> { let toml_file = EXPERTS_BUNDLE .get_file(EXPERTS_TOML) .ok_or_else(|| ExpertsError::Metadata(format!("{EXPERTS_TOML} missing from bundle")))?; let toml_str = toml_file .contents_utf8() .ok_or_else(|| ExpertsError::Metadata(format!("{EXPERTS_TOML} is not valid UTF-8")))?; let root: ExpertsTomlRoot = toml::from_str(toml_str) .map_err(|e| ExpertsError::Metadata(format!("failed to parse {EXPERTS_TOML}: {e}")))?; let mut out = Vec::with_capacity(root.expert.len()); for entry in root.expert { let bundled_hash = hash_bundled_expert(&entry.id)?; out.push(ExpertMetadata { id: entry.id, category: entry.category, icon: entry.icon, sort_order: entry.sort_order, display_name: entry.display_name, description: entry.description, bundled_hash, }); } out.sort_by(|a, b| { a.sort_order .cmp(&b.sort_order) .then_with(|| a.id.cmp(&b.id)) }); Ok(out) } fn find_metadata(expert_id: &str) -> Result<&'static ExpertMetadata, ExpertsError> { bundled_metadata() .iter() .find(|m| m.id == expert_id) .ok_or_else(|| ExpertsError::NotFound(expert_id.to_string())) } // ─── Hashing ──────────────────────────────────────────────────────────── fn hash_bundled_expert(expert_id: &str) -> Result { let skill_dir = format!("skills/{expert_id}"); let dir = EXPERTS_BUNDLE .get_dir(&skill_dir) .ok_or_else(|| ExpertsError::NotFound(expert_id.to_string()))?; let mut files: Vec<(&str, &[u8])> = Vec::new(); collect_bundle_files(dir, &mut files); files.sort_by_key(|(path, _)| *path); let mut hasher = Sha256::new(); for (path, contents) in files { hasher.update(path.as_bytes()); hasher.update(b"\0"); hasher.update(contents); hasher.update(b"\0"); } Ok(format!("{:x}", hasher.finalize())) } fn collect_bundle_files<'a>(dir: &'a Dir<'a>, out: &mut Vec<(&'a str, &'a [u8])>) { for entry in dir.entries() { match entry { DirEntry::File(f) => { let rel = f.path().to_str().unwrap_or(""); out.push((rel, f.contents())); } DirEntry::Dir(d) => collect_bundle_files(d, out), } } } fn hash_disk_directory(path: &Path) -> Result { let mut files: Vec<(String, Vec)> = Vec::new(); collect_disk_files(path, path, &mut files)?; files.sort_by(|a, b| a.0.cmp(&b.0)); let mut hasher = Sha256::new(); for (rel_path, contents) in files { // Mirror the bundled hash format: relative path includes the // leading `skills//` prefix from bundled view. let logical = format!( "skills/{}/{}", path.file_name() .and_then(|s| s.to_str()) .unwrap_or_default(), rel_path ); hasher.update(logical.as_bytes()); hasher.update(b"\0"); hasher.update(&contents); hasher.update(b"\0"); } Ok(format!("{:x}", hasher.finalize())) } fn collect_disk_files( base: &Path, current: &Path, out: &mut Vec<(String, Vec)>, ) -> Result<(), ExpertsError> { if !current.exists() { return Ok(()); } for entry in fs::read_dir(current)? { let entry = entry?; let file_type = entry.file_type()?; let child = entry.path(); if file_type.is_dir() { collect_disk_files(base, &child, out)?; } else if file_type.is_file() { let rel = child .strip_prefix(base) .map_err(|e| ExpertsError::Io(e.to_string()))? .to_string_lossy() .replace('\\', "/"); let contents = fs::read(&child)?; out.push((rel, contents)); } } Ok(()) } // ─── Manifest I/O ─────────────────────────────────────────────────────── fn load_manifest() -> Manifest { let path = manifest_path(); match fs::read_to_string(&path) { Ok(content) => serde_json::from_str::(&content).unwrap_or_default(), Err(_) => Manifest::default(), } } fn save_manifest(manifest: &Manifest) -> Result<(), ExpertsError> { let path = manifest_path(); if let Some(parent) = path.parent() { fs::create_dir_all(parent)?; } let serialized = serde_json::to_string_pretty(manifest) .map_err(|e| ExpertsError::Metadata(format!("failed to serialize manifest: {e}")))?; fs::write(&path, serialized)?; Ok(()) } // ─── Link operations ──────────────────────────────────────────────────── #[cfg(unix)] fn create_link_raw(src: &Path, dst: &Path) -> io::Result { std::os::unix::fs::symlink(src, dst).map(|_| false) } #[cfg(windows)] fn create_link_raw(src: &Path, dst: &Path) -> io::Result { match junction::create(src, dst) { Ok(_) => Ok(false), Err(junction_err) => { // Fall back to recursive copy when junction creation is not // possible (e.g. cross-volume, permission denied). Mark the // returned status with copy_mode = true so the UI can warn // the user that upgrades won't propagate automatically. copy_dir_recursive(src, dst).map_err(|copy_err| { io::Error::new( io::ErrorKind::Other, format!( "junction failed ({junction_err}); copy fallback failed ({copy_err})" ), ) })?; Ok(true) } } } #[cfg(windows)] fn copy_dir_recursive(src: &Path, dst: &Path) -> io::Result<()> { fs::create_dir_all(dst)?; for entry in fs::read_dir(src)? { let entry = entry?; let ft = entry.file_type()?; let from = entry.path(); let to = dst.join(entry.file_name()); if ft.is_dir() { copy_dir_recursive(&from, &to)?; } else if ft.is_file() { fs::copy(&from, &to)?; } } Ok(()) } fn read_link_target(path: &Path) -> Option { fs::read_link(path).ok() } fn path_is_symlink(path: &Path) -> bool { fs::symlink_metadata(path) .map(|m| m.file_type().is_symlink()) .unwrap_or(false) } /// On Windows a junction is *not* a symlink — it is a directory reparse /// point. `symlink_metadata` reports it as a directory. So we also need to /// ask the OS whether the directory is a reparse point. #[cfg(windows)] fn path_is_reparse_point(path: &Path) -> bool { use std::os::windows::fs::MetadataExt; const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0000_0400; fs::symlink_metadata(path) .map(|m| m.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0) .unwrap_or(false) } #[cfg(not(windows))] fn path_is_reparse_point(_path: &Path) -> bool { false } fn classify_link(link_path: &Path, expected_target: &Path) -> ExpertLinkState { if !link_path.exists() && !path_is_symlink(link_path) { return ExpertLinkState::NotLinked; } // Broken symlink detection: `exists()` returns false if the link // dangles; but we already checked that. Re-check the metadata. match fs::symlink_metadata(link_path) { Ok(meta) => { let is_link_like = meta.file_type().is_symlink() || path_is_reparse_point(link_path); if !is_link_like { return ExpertLinkState::BlockedByRealDirectory; } // Verify the target. match read_link_target(link_path) { Some(target) => { // Normalize both sides by canonicalizing the expected // target. The link target may be relative. let target = if target.is_absolute() { target } else if let Some(parent) = link_path.parent() { parent.join(target) } else { target }; let canonical_target = fs::canonicalize(&target).ok(); let canonical_expected = fs::canonicalize(expected_target).ok(); match (canonical_target.as_ref(), canonical_expected.as_ref()) { (Some(t), Some(e)) if t == e => ExpertLinkState::LinkedToCodeg, (Some(_), Some(_)) => ExpertLinkState::LinkedElsewhere, (None, _) => ExpertLinkState::Broken, (_, None) => ExpertLinkState::LinkedElsewhere, } } None => { // On Windows junctions, `read_link` may fail but the // directory still resolves. Fall back to canonical // comparison via the path itself. let canonical_link = fs::canonicalize(link_path).ok(); let canonical_expected = fs::canonicalize(expected_target).ok(); match (canonical_link, canonical_expected) { (Some(t), Some(e)) if t == e => ExpertLinkState::LinkedToCodeg, _ => ExpertLinkState::LinkedElsewhere, } } } } Err(_) => ExpertLinkState::NotLinked, } } // ─── Central store installation ──────────────────────────────────────── pub async fn ensure_central_experts_installed() -> InstallReport { let _guard = mutation_lock().lock().await; tokio::task::spawn_blocking(ensure_central_experts_installed_blocking) .await .unwrap_or_else(|e| { let mut r = InstallReport::default(); r.errors.push(format!("join error: {e}")); r }) } fn ensure_central_experts_installed_blocking() -> InstallReport { let mut report = InstallReport::default(); let central = central_experts_dir(); if let Err(e) = fs::create_dir_all(¢ral) { report .errors .push(format!("failed to create central dir: {e}")); return report; } let mut manifest = load_manifest(); let meta_list = bundled_metadata(); for meta in meta_list { match install_or_refresh_expert(meta, &mut manifest) { Ok(InstallAction::Skipped) => {} Ok(InstallAction::Installed) => { report.installed_count += 1; } Ok(InstallAction::Updated) => { report.updated_count += 1; } Ok(InstallAction::BackedUp) => { report.updated_count += 1; report.pending_user_review.push(meta.id.clone()); } Err(e) => { report .errors .push(format!("{}: {}", meta.id, e)); } } } manifest.codeg_version = env!("CARGO_PKG_VERSION").to_string(); manifest.installed_at = Utc::now().to_rfc3339(); if let Err(e) = save_manifest(&manifest) { report.errors.push(format!("save manifest: {e}")); } report } enum InstallAction { Skipped, Installed, Updated, BackedUp, } fn install_or_refresh_expert( meta: &ExpertMetadata, manifest: &mut Manifest, ) -> Result { let central_path = expert_central_path(&meta.id); let bundled_hash = &meta.bundled_hash; let manifest_entry = manifest.experts.get(&meta.id).cloned().unwrap_or_default(); if central_path.exists() { let on_disk_hash = hash_disk_directory(¢ral_path).unwrap_or_default(); if &on_disk_hash == bundled_hash { // Up-to-date and pristine. Ensure manifest matches. if manifest_entry.hash != *bundled_hash { manifest.experts.insert( meta.id.clone(), ManifestEntry { hash: bundled_hash.clone(), installed_at: Utc::now().to_rfc3339(), pending_user_review: false, }, ); } return Ok(InstallAction::Skipped); } // Content differs. Was the user the one who changed it, or is // the bundle itself newer? let user_modified = manifest_entry.hash.is_empty() || on_disk_hash != manifest_entry.hash; if user_modified { // Preserve user work: move aside, install fresh. let backup_name = format!( "{}.user-backup-{}", meta.id, Utc::now().format("%Y%m%d-%H%M%S") ); let backup_path = central_experts_dir().join(backup_name); fs::rename(¢ral_path, &backup_path)?; extract_expert_to_disk(meta, ¢ral_path)?; manifest.experts.insert( meta.id.clone(), ManifestEntry { hash: bundled_hash.clone(), installed_at: Utc::now().to_rfc3339(), pending_user_review: true, }, ); return Ok(InstallAction::BackedUp); } // Pristine but outdated → overwrite. remove_skill_entry(¢ral_path) .map_err(|e| ExpertsError::Io(format!("remove stale expert: {e}")))?; extract_expert_to_disk(meta, ¢ral_path)?; manifest.experts.insert( meta.id.clone(), ManifestEntry { hash: bundled_hash.clone(), installed_at: Utc::now().to_rfc3339(), pending_user_review: false, }, ); Ok(InstallAction::Updated) } else { extract_expert_to_disk(meta, ¢ral_path)?; manifest.experts.insert( meta.id.clone(), ManifestEntry { hash: bundled_hash.clone(), installed_at: Utc::now().to_rfc3339(), pending_user_review: false, }, ); Ok(InstallAction::Installed) } } fn extract_expert_to_disk(meta: &ExpertMetadata, target: &Path) -> Result<(), ExpertsError> { let skill_rel = format!("skills/{}", meta.id); let dir = EXPERTS_BUNDLE .get_dir(&skill_rel) .ok_or_else(|| ExpertsError::NotFound(meta.id.clone()))?; fs::create_dir_all(target)?; extract_bundle_dir(dir, &skill_rel, target)?; Ok(()) } fn extract_bundle_dir( dir: &Dir<'_>, bundle_prefix: &str, target: &Path, ) -> Result<(), ExpertsError> { for entry in dir.entries() { match entry { DirEntry::File(f) => { let rel = f .path() .to_str() .ok_or_else(|| ExpertsError::Io("non-utf8 path in bundle".into()))?; let rel_within = rel.strip_prefix(bundle_prefix) .and_then(|s| s.strip_prefix('/')) .unwrap_or(rel); let out_path = target.join(rel_within); if let Some(parent) = out_path.parent() { fs::create_dir_all(parent)?; } fs::write(&out_path, f.contents())?; } DirEntry::Dir(d) => { extract_bundle_dir(d, bundle_prefix, target)?; } } } Ok(()) } // ─── Commands: list / status ──────────────────────────────────────────── #[cfg_attr(feature = "tauri-runtime", tauri::command)] pub async fn experts_list() -> Result, ExpertsError> { let meta_list = bundled_metadata().to_vec(); let manifest = load_manifest(); let mut out = Vec::with_capacity(meta_list.len()); for meta in meta_list { let central_path = expert_central_path(&meta.id); let installed_centrally = central_path.exists(); let user_modified = manifest .experts .get(&meta.id) .map(|e| e.pending_user_review) .unwrap_or(false); out.push(ExpertListItem { metadata: meta, installed_centrally, user_modified, central_path: central_path.to_string_lossy().to_string(), }); } Ok(out) } #[cfg_attr(feature = "tauri-runtime", tauri::command)] pub async fn experts_list_for_agent( agent_type: AgentType, ) -> Result, ExpertsError> { let _ = skill_storage_spec(agent_type) .ok_or(ExpertsError::UnsupportedAgent(agent_type))?; let dirs = scoped_skill_dirs(agent_type, AgentSkillScope::Global, None) .map_err(|_| ExpertsError::UnsupportedAgent(agent_type))?; let meta_list = bundled_metadata().to_vec(); let manifest = load_manifest(); let mut out = Vec::new(); for meta in meta_list { let central_path = expert_central_path(&meta.id); let is_linked = dirs.iter().any(|dir| { let candidate = dir.join(&meta.id); classify_link(&candidate, ¢ral_path) == ExpertLinkState::LinkedToCodeg }); if !is_linked { continue; } let installed_centrally = central_path.exists(); let user_modified = manifest .experts .get(&meta.id) .map(|e| e.pending_user_review) .unwrap_or(false); out.push(ExpertListItem { metadata: meta, installed_centrally, user_modified, central_path: central_path.to_string_lossy().to_string(), }); } Ok(out) } #[cfg_attr(feature = "tauri-runtime", tauri::command)] pub async fn experts_get_install_status( expert_id: String, ) -> Result, ExpertsError> { let expert_id = validate_skill_id(&expert_id) .map_err(|e| ExpertsError::Metadata(e.to_string()))?; let _ = find_metadata(&expert_id)?; // ensure it exists in the bundle let expected = expert_central_path(&expert_id); let agents = supported_agents(); let mut out = Vec::with_capacity(agents.len()); for agent in agents { let link_path = match agent_link_path(agent, &expert_id) { Ok(p) => p, Err(_) => continue, }; let state = classify_link(&link_path, &expected); let target_path = read_link_target(&link_path).map(|p| p.to_string_lossy().to_string()); out.push(ExpertInstallStatus { expert_id: expert_id.clone(), agent_type: agent, state, link_path: link_path.to_string_lossy().to_string(), target_path, expected_target_path: expected.to_string_lossy().to_string(), copy_mode: false, }); } Ok(out) } fn supported_agents() -> Vec { const ALL: &[AgentType] = &[ AgentType::ClaudeCode, AgentType::Codex, AgentType::OpenCode, AgentType::Gemini, AgentType::OpenClaw, AgentType::Cline, ]; ALL.iter() .filter(|a| skill_storage_spec(**a).is_some()) .copied() .collect() } // ─── Commands: link / unlink ──────────────────────────────────────────── #[cfg_attr(feature = "tauri-runtime", tauri::command)] pub async fn experts_link_to_agent( expert_id: String, agent_type: AgentType, ) -> Result { let expert_id = validate_skill_id(&expert_id) .map_err(|e| ExpertsError::Metadata(e.to_string()))?; let _ = find_metadata(&expert_id)?; let central = expert_central_path(&expert_id); if !central.exists() { return Err(ExpertsError::CentralUnavailable(format!( "expert '{expert_id}' is not installed in central store" ))); } let _guard = mutation_lock().lock().await; let link_path = agent_link_path(agent_type, &expert_id)?; if let Some(parent) = link_path.parent() { fs::create_dir_all(parent)?; } let mut copy_mode = false; match create_link_raw(¢ral, &link_path) { Ok(is_copy) => { copy_mode = is_copy; } Err(err) if err.kind() == io::ErrorKind::AlreadyExists => { // Already exists — figure out what kind. match classify_link(&link_path, ¢ral) { ExpertLinkState::LinkedToCodeg => { // Idempotent success. } ExpertLinkState::BlockedByRealDirectory => { return Err(ExpertsError::NameCollision { path: link_path.to_string_lossy().to_string(), }); } ExpertLinkState::LinkedElsewhere | ExpertLinkState::Broken => { let found = read_link_target(&link_path) .map(|p| p.to_string_lossy().to_string()) .unwrap_or_else(|| "".into()); return Err(ExpertsError::ForeignLink { path: link_path.to_string_lossy().to_string(), found, }); } ExpertLinkState::NotLinked => { // Shouldn't happen after AlreadyExists, but retry once. create_link_raw(¢ral, &link_path).map_err(|e| ExpertsError::Io(format!( "retry link failed: {e}" )))?; } } } Err(err) => return Err(ExpertsError::Io(err.to_string())), } let state = classify_link(&link_path, ¢ral); let target_path = read_link_target(&link_path).map(|p| p.to_string_lossy().to_string()); Ok(ExpertInstallStatus { expert_id: expert_id.clone(), agent_type, state, link_path: link_path.to_string_lossy().to_string(), target_path, expected_target_path: central.to_string_lossy().to_string(), copy_mode, }) } #[cfg_attr(feature = "tauri-runtime", tauri::command)] pub async fn experts_unlink_from_agent( expert_id: String, agent_type: AgentType, ) -> Result<(), ExpertsError> { let expert_id = validate_skill_id(&expert_id) .map_err(|e| ExpertsError::Metadata(e.to_string()))?; let _guard = mutation_lock().lock().await; // Scan ALL global dirs for this agent to handle shared-dir agents. // Remove the link wherever it is found. let dirs = scoped_skill_dirs(agent_type, AgentSkillScope::Global, None) .map_err(|_| ExpertsError::UnsupportedAgent(agent_type))?; let central = expert_central_path(&expert_id); let mut removed = false; for dir in dirs { let candidate = dir.join(&expert_id); if !candidate.exists() && !path_is_symlink(&candidate) { continue; } let state = classify_link(&candidate, ¢ral); if matches!(state, ExpertLinkState::LinkedToCodeg | ExpertLinkState::Broken) { // Safe to remove a link to our central store or a broken link. remove_skill_entry(&candidate) .map_err(|e| ExpertsError::Io(format!("remove link {}: {e}", candidate.display())))?; removed = true; } else if state == ExpertLinkState::LinkedElsewhere { return Err(ExpertsError::ForeignLink { path: candidate.to_string_lossy().to_string(), found: read_link_target(&candidate) .map(|p| p.to_string_lossy().to_string()) .unwrap_or_else(|| "".into()), }); } else if state == ExpertLinkState::BlockedByRealDirectory { // Not ours; leave alone. continue; } } if !removed { // It was already unlinked — treat as idempotent success. } Ok(()) } // ─── Commands: read / open ────────────────────────────────────────────── #[cfg_attr(feature = "tauri-runtime", tauri::command)] pub async fn experts_read_content(expert_id: String) -> Result { let expert_id = validate_skill_id(&expert_id) .map_err(|e| ExpertsError::Metadata(e.to_string()))?; let _ = find_metadata(&expert_id)?; let path = expert_central_path(&expert_id).join("SKILL.md"); if !path.exists() { // Fall back to bundled copy when central store isn't populated. let bundled_rel = format!("skills/{expert_id}/SKILL.md"); if let Some(f) = EXPERTS_BUNDLE.get_file(&bundled_rel) { if let Some(text) = f.contents_utf8() { return Ok(text.to_string()); } } return Err(ExpertsError::CentralUnavailable(format!( "expert '{expert_id}' has no SKILL.md on disk" ))); } let content = fs::read_to_string(&path)?; Ok(content) } #[cfg_attr(feature = "tauri-runtime", tauri::command)] pub async fn experts_open_central_dir() -> Result { let dir = central_experts_dir(); fs::create_dir_all(&dir)?; Ok(dir.to_string_lossy().to_string()) }