use anyhow::{anyhow, Context, Result}; use once_cell::sync::Lazy; use regex::Regex; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::path::{Path, PathBuf}; use std::time::{Duration, Instant}; use tokio::process::Command; use tokio::sync::Mutex; use uuid::Uuid; use crate::opencode_config; static SKILL_NAME_RE: Lazy = Lazy::new(|| Regex::new(r"^[a-z0-9]([a-z0-9-]*[a-z0-9])?$").expect("valid skill name regex")); static AUTH_ERROR_RE: Lazy = Lazy::new(|| { Regex::new(r"(?i)(permission denied|publickey|could not read from remote repository|authentication failed)") .expect("valid auth error regex") }); const CACHE_TTL: Duration = Duration::from_secs(30 * 60); #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct SkillsCatalogSource { pub id: String, pub label: String, #[serde(skip_serializing_if = "Option::is_none")] pub description: Option, pub source: String, #[serde(skip_serializing_if = "Option::is_none")] pub default_subpath: Option, #[serde(skip_serializing)] pub git_identity_id: Option, } #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct SkillsCatalogInstalledBadge { pub is_installed: bool, #[serde(skip_serializing_if = "Option::is_none")] pub scope: Option, } #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct ClawdHubSkillMetadata { pub slug: String, pub version: String, #[serde(skip_serializing_if = "Option::is_none")] pub display_name: Option, #[serde(skip_serializing_if = "Option::is_none")] pub owner: Option, #[serde(skip_serializing_if = "Option::is_none")] pub downloads: Option, #[serde(skip_serializing_if = "Option::is_none")] pub stars: Option, } #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct SkillsCatalogItem { pub source_id: String, pub repo_source: String, #[serde(skip_serializing_if = "Option::is_none")] pub repo_subpath: Option, #[serde(skip_serializing_if = "Option::is_none")] pub git_identity_id: Option, pub skill_dir: String, pub skill_name: String, #[serde(skip_serializing_if = "Option::is_none")] pub frontmatter_name: Option, #[serde(skip_serializing_if = "Option::is_none")] pub description: Option, pub installable: bool, #[serde(skip_serializing_if = "Option::is_none")] pub warnings: Option>, pub installed: SkillsCatalogInstalledBadge, #[serde(skip_serializing_if = "Option::is_none")] pub clawdhub: Option, } #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct SkillsCatalogResponse { pub ok: bool, #[serde(skip_serializing_if = "Option::is_none")] pub sources: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub items_by_source: Option>>, #[serde(skip_serializing_if = "Option::is_none")] pub error: Option, } #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct SkillsRepoScanResponse { pub ok: bool, #[serde(skip_serializing_if = "Option::is_none")] pub items: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub error: Option, } #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct SkillsInstallResponse { pub ok: bool, #[serde(skip_serializing_if = "Option::is_none")] pub installed: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub skipped: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub error: Option, } #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct InstalledSkill { pub skill_name: String, pub scope: String, } #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct SkippedSkill { pub skill_name: String, pub reason: String, } #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct SkillConflict { pub skill_name: String, pub scope: String, } #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct IdentitySummary { pub id: String, pub name: String, } #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct SkillsRepoError { pub kind: String, pub message: String, #[serde(skip_serializing_if = "Option::is_none")] pub ssh_only: Option, #[serde(skip_serializing_if = "Option::is_none")] pub identities: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub conflicts: Option>, } #[derive(Debug, Clone)] struct RepoParsed { normalized_repo: String, clone_https: String, clone_ssh: String, effective_subpath: Option, } #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] struct GitIdentityWrapper { profiles: Vec, } #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] struct GitIdentityProfile { id: String, name: String, #[serde(default)] ssh_key: Option, } fn identities_storage_path() -> Result { let mut path = dirs::home_dir().ok_or_else(|| anyhow!("Could not find home directory"))?; path.push(".config"); path.push("openchamber"); path.push("git-identities.json"); Ok(path) } fn list_identities() -> Vec { let Ok(path) = identities_storage_path() else { return vec![]; }; let Ok(content) = std::fs::read_to_string(path) else { return vec![]; }; let Ok(wrapper) = serde_json::from_str::(&content) else { return vec![]; }; wrapper .profiles .into_iter() .map(|p| IdentitySummary { id: p.id, name: p.name, }) .collect() } fn resolve_identity_ssh_key(identity_id: Option<&str>) -> Option { let id = identity_id?.trim(); if id.is_empty() { return None; } let path = identities_storage_path().ok()?; let content = std::fs::read_to_string(path).ok()?; let wrapper = serde_json::from_str::(&content).ok()?; wrapper .profiles .into_iter() .find(|p| p.id == id) .and_then(|p| p.ssh_key) .map(|s| s.trim().to_string()) .filter(|s| !s.is_empty()) } fn parse_repo_source(source: &str, subpath: Option<&str>) -> Result { let raw = source.trim(); if raw.is_empty() { return Err(anyhow!("Repository source is required")); } let explicit_subpath = subpath .map(|s| s.trim().to_string()) .filter(|s| !s.is_empty()); // SSH URL let ssh_re = Regex::new(r"^git@github\.com:([^/\s]+)/([^\s#]+)$").unwrap(); if let Some(caps) = ssh_re.captures(raw) { let owner = caps.get(1).unwrap().as_str(); let repo = caps.get(2).unwrap().as_str().trim_end_matches(".git"); return Ok(RepoParsed { normalized_repo: format!("{}/{}", owner, repo), clone_https: format!("https://github.com/{}/{}.git", owner, repo), clone_ssh: format!("git@github.com:{}/{}.git", owner, repo), effective_subpath: explicit_subpath, }); } // HTTPS URL let https_re = Regex::new(r"^https?://github\.com/([^/\s]+)/([^\s#]+)$").unwrap(); if let Some(caps) = https_re.captures(raw) { let owner = caps.get(1).unwrap().as_str(); let repo = caps.get(2).unwrap().as_str().trim_end_matches(".git"); return Ok(RepoParsed { normalized_repo: format!("{}/{}", owner, repo), clone_https: format!("https://github.com/{}/{}.git", owner, repo), clone_ssh: format!("git@github.com:{}/{}.git", owner, repo), effective_subpath: explicit_subpath, }); } // Shorthand owner/repo[/subpath] let shorthand_re = Regex::new(r"^([^/\s]+)/([^/\s]+)(?:/(.+))?$").unwrap(); if let Some(caps) = shorthand_re.captures(raw) { let owner = caps.get(1).unwrap().as_str(); let repo = caps.get(2).unwrap().as_str().trim_end_matches(".git"); let shorthand_subpath = caps .get(3) .map(|m| m.as_str().trim().to_string()) .filter(|s| !s.is_empty()); return Ok(RepoParsed { normalized_repo: format!("{}/{}", owner, repo), clone_https: format!("https://github.com/{}/{}.git", owner, repo), clone_ssh: format!("git@github.com:{}/{}.git", owner, repo), effective_subpath: explicit_subpath.or(shorthand_subpath), }); } Err(anyhow!("Unsupported repository source format")) } fn validate_skill_name(name: &str) -> bool { if name.len() < 1 || name.len() > 64 { return false; } SKILL_NAME_RE.is_match(name) } fn parse_skill_md_frontmatter(contents: &str) -> (Option, Option, Vec) { // Expect: // --- // yaml // --- // body... let mut warnings = vec![]; if !contents.starts_with("---") { warnings.push("Invalid SKILL.md: missing YAML frontmatter delimiter".to_string()); return (None, None, warnings); } let parts: Vec<&str> = contents.splitn(3, "---").collect(); if parts.len() < 3 { warnings.push("Invalid SKILL.md: missing YAML frontmatter delimiter".to_string()); return (None, None, warnings); } let yaml_text = parts[1]; let parsed: serde_yaml::Value = match serde_yaml::from_str(yaml_text) { Ok(v) => v, Err(_) => { warnings.push("Invalid SKILL.md: failed to parse YAML frontmatter".to_string()); return (None, None, warnings); } }; let name = parsed .get("name") .and_then(|v| v.as_str()) .map(|s| s.to_string()); let description = parsed .get("description") .and_then(|v| v.as_str()) .map(|s| s.to_string()); (name, description, warnings) } async fn run_git( args: &[String], cwd: &Path, ssh_key: Option<&str>, timeout: Duration, ) -> Result<(String, String)> { let mut cmd = Command::new("git"); if let Some(key) = ssh_key { let key = key.trim(); if !key.is_empty() { let ssh_command = format!( "ssh -i {} -o BatchMode=yes -o StrictHostKeyChecking=accept-new", key ); cmd.arg("-c") .arg(format!("core.sshCommand={}", ssh_command)); } } cmd.args(args) .current_dir(cwd) .stdin(std::process::Stdio::null()) .kill_on_drop(true) .env("GIT_TERMINAL_PROMPT", "0") .env("GCM_INTERACTIVE", "Never") .env("LC_ALL", "C"); let output = tokio::time::timeout(timeout, cmd.output()) .await .map_err(|_| anyhow!("Git command timed out"))? .context("Failed to execute git command")?; let stdout = String::from_utf8_lossy(&output.stdout).to_string(); let stderr = String::from_utf8_lossy(&output.stderr).to_string(); if !output.status.success() { let combined = format!("{}\n{}", stderr, stdout); return Err(anyhow!(combined.trim().to_string())); } Ok((stdout, stderr)) } fn auth_required_error(message: &str) -> SkillsRepoError { SkillsRepoError { kind: "authRequired".to_string(), message: message.to_string(), ssh_only: Some(true), identities: Some(list_identities()), conflicts: None, } } fn simple_error(kind: &str, message: &str) -> SkillsRepoError { SkillsRepoError { kind: kind.to_string(), message: message.to_string(), ssh_only: None, identities: None, conflicts: None, } } fn conflicts_error(conflicts: Vec) -> SkillsRepoError { SkillsRepoError { kind: "conflicts".to_string(), message: "Some skills already exist in the selected scope".to_string(), ssh_only: None, identities: None, conflicts: Some(conflicts), } } async fn clone_repo(clone_url: &str, target_dir: &Path, ssh_key: Option<&str>) -> Result<()> { let preferred = vec![ "clone".to_string(), "--depth".to_string(), "1".to_string(), "--filter=blob:none".to_string(), "--no-checkout".to_string(), clone_url.to_string(), target_dir.display().to_string(), ]; let fallback = vec![ "clone".to_string(), "--depth".to_string(), "1".to_string(), "--no-checkout".to_string(), clone_url.to_string(), target_dir.display().to_string(), ]; let cwd = std::env::temp_dir(); if run_git(&preferred, &cwd, ssh_key, Duration::from_secs(60)) .await .is_ok() { return Ok(()); } run_git(&fallback, &cwd, ssh_key, Duration::from_secs(60)).await?; Ok(()) } async fn safe_rm(dir: &Path) { let _ = tokio::fs::remove_dir_all(dir).await; } async fn scan_repo_items( source: &str, subpath: Option<&str>, default_subpath: Option<&str>, ssh_key: Option<&str>, ) -> Result<( String, Option, Vec<( String, String, Option, Option, Vec, bool, )>, )> { let parsed = parse_repo_source(source, subpath)?; let effective_subpath = parsed .effective_subpath .clone() .or_else(|| default_subpath.map(|s| s.to_string())) .filter(|s| !s.trim().is_empty()); let clone_url = if ssh_key.is_some() { parsed.clone_ssh.clone() } else { parsed.clone_https.clone() }; let temp_base = std::env::temp_dir().join(format!( "openchamber-desktop-skills-scan-{}", Uuid::new_v4() )); // Clone into temp_base (directory must not exist for git clone target) let _ = tokio::fs::remove_dir_all(&temp_base).await; let clone_res = clone_repo(&clone_url, &temp_base, ssh_key).await; if let Err(err) = clone_res { let msg = err.to_string(); if AUTH_ERROR_RE.is_match(&msg) { return Err(anyhow!("AUTH_REQUIRED")); } return Err(anyhow!(msg)); } // Fast path: sparse checkout only SKILL.md files, then read them from disk. // This avoids spawning `git show` per skill. let patterns: Vec = if let Some(ref sp) = effective_subpath { vec![format!("{}/SKILL.md", sp), format!("{}/**/SKILL.md", sp)] } else { vec!["SKILL.md".to_string(), "**/SKILL.md".to_string()] }; let sparse_init = run_git( &vec![ "-C".to_string(), temp_base.display().to_string(), "sparse-checkout".to_string(), "init".to_string(), "--no-cone".to_string(), ], &std::env::temp_dir(), ssh_key, Duration::from_secs(15), ) .await; let mut skill_md_paths: Vec = vec![]; if sparse_init.is_ok() { let mut set_args = vec![ "-C".to_string(), temp_base.display().to_string(), "sparse-checkout".to_string(), "set".to_string(), ]; set_args.extend(patterns.clone()); let sparse_set = run_git( &set_args, &std::env::temp_dir(), ssh_key, Duration::from_secs(30), ) .await; if sparse_set.is_ok() { let checkout = run_git( &vec![ "-C".to_string(), temp_base.display().to_string(), "checkout".to_string(), "--force".to_string(), "HEAD".to_string(), ], &std::env::temp_dir(), ssh_key, Duration::from_secs(60), ) .await; if checkout.is_ok() { let ls_files = run_git( &vec![ "-C".to_string(), temp_base.display().to_string(), "ls-files".to_string(), ], &std::env::temp_dir(), ssh_key, Duration::from_secs(15), ) .await; if let Ok((out, _)) = ls_files { skill_md_paths = out .lines() .map(|l| l.trim().to_string()) .filter(|l| !l.is_empty()) .filter(|p| p.ends_with("/SKILL.md") || p == "SKILL.md") .collect(); } } } } // Fallback: use ls-tree to find SKILL.md paths. if skill_md_paths.is_empty() { let mut list_args = vec![ "-C".to_string(), temp_base.display().to_string(), "ls-tree".to_string(), "-r".to_string(), "--name-only".to_string(), "HEAD".to_string(), ]; if let Some(ref sp) = effective_subpath { list_args.push("--".to_string()); list_args.push(sp.clone()); } let list_out = run_git( &list_args, &std::env::temp_dir(), ssh_key, Duration::from_secs(30), ) .await; let stdout = match list_out { Ok((out, _)) => out, Err(_) => { safe_rm(&temp_base).await; return Ok((parsed.normalized_repo, effective_subpath, vec![])); } }; skill_md_paths = stdout .lines() .map(|l| l.trim().to_string()) .filter(|l| !l.is_empty()) .filter(|p| p.ends_with("/SKILL.md") || p == "SKILL.md") .collect(); } let mut skill_dirs: Vec = skill_md_paths .into_iter() .filter(|p| p != "SKILL.md") .map(|p| { let dir = Path::new(&p) .parent() .map(|d| d.to_string_lossy().to_string()) .unwrap_or_else(|| "".to_string()); dir.replace('\\', "/") }) .collect(); skill_dirs.sort(); skill_dirs.dedup(); let mut items = vec![]; for skill_dir in skill_dirs { let skill_name = skill_dir .split('/') .filter(|s| !s.is_empty()) .last() .unwrap_or("") .to_string(); if skill_name.is_empty() { continue; } let mut warnings = vec![]; let skill_md_repo_path = if skill_dir.is_empty() { "SKILL.md".to_string() } else { format!("{}/SKILL.md", skill_dir) }; let skill_md_fs_path = repo_path_to_fs(&temp_base, &skill_md_repo_path); let contents = match tokio::fs::read_to_string(&skill_md_fs_path).await { Ok(text) => text, Err(_) => { // Fallback to git show if the file is not present in working tree. let show_args = vec![ "-C".to_string(), temp_base.display().to_string(), "show".to_string(), format!("HEAD:{}", skill_md_repo_path), ]; match run_git( &show_args, &std::env::temp_dir(), ssh_key, Duration::from_secs(15), ) .await { Ok((out, _)) => out, Err(_) => { warnings.push("Failed to read SKILL.md".to_string()); String::new() } } } }; let (frontmatter_name, description, mut fm_warnings) = parse_skill_md_frontmatter(&contents); warnings.append(&mut fm_warnings); let installable = validate_skill_name(&skill_name); if !installable { warnings.push("Skill directory name is not a valid OpenCode skill name".to_string()); } items.push(( source.to_string(), skill_dir, frontmatter_name, description, warnings, installable, )); } safe_rm(&temp_base).await; Ok((parsed.normalized_repo, effective_subpath, items)) } #[derive(Debug, Clone)] struct CacheEntry { created_at: Instant, items: Vec, } static CATALOG_CACHE: Lazy>> = Lazy::new(|| Mutex::new(HashMap::new())); fn cache_key(normalized_repo: &str, subpath: Option<&str>, identity_id: Option<&str>) -> String { format!( "{}::{}::{}", normalized_repo, subpath.unwrap_or(""), identity_id.unwrap_or("") ) } // ============== ClawdHub API ============== const CLAWDHUB_API_BASE: &str = "https://clawdhub.com/api/v1"; const CLAWDHUB_PAGE_LIMIT: usize = 25; fn is_clawdhub_source(source: &str) -> bool { source.starts_with("clawdhub:") } #[derive(Debug, Deserialize)] struct ClawdHubSkillOwner { handle: Option, } #[derive(Debug, Deserialize)] struct ClawdHubSkillStats { downloads: Option, stars: Option, } #[derive(Debug, Deserialize)] struct ClawdHubSkillTags { latest: Option, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct ClawdHubSkillVersion { version: Option, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct ClawdHubSkillListItem { slug: String, display_name: Option, summary: Option, tags: Option, latest_version: Option, stats: Option, owner: Option, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct ClawdHubSkillsResponse { items: Vec, next_cursor: Option, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct ClawdHubSkillInfoResponse { skill: Option, latest_version: Option, } #[derive(Debug, Deserialize)] struct ClawdHubSkillInfoSkill { tags: Option, } async fn scan_clawdhub() -> Result> { let client = reqwest::Client::builder() .user_agent("OpenChamber-Desktop/1.0") .timeout(Duration::from_secs(30)) .build()?; let mut all_items = Vec::new(); let mut cursor: Option = None; let max_pages = 20; for page in 0..max_pages { let url = match &cursor { Some(c) => format!( "{}{}?cursor={}&limit={}", CLAWDHUB_API_BASE, "/skills", urlencoding::encode(c), CLAWDHUB_PAGE_LIMIT ), None => format!("{}/skills?limit={}", CLAWDHUB_API_BASE, CLAWDHUB_PAGE_LIMIT), }; let mut response: Option = None; let max_attempts = 10; for attempt in 0..max_attempts { let resp = client.get(&url).send().await?; if resp.status().is_success() { response = Some(resp); break; } let should_retry = (resp.status() == reqwest::StatusCode::TOO_MANY_REQUESTS || resp.status().is_server_error()) && attempt + 1 < max_attempts; if should_retry { tokio::time::sleep(Duration::from_millis(50 * (attempt + 1) as u64)).await; continue; } if page > 0 && !all_items.is_empty() { break; } return Err(anyhow!("ClawdHub API error: {}", resp.status())); } let Some(response) = response else { break; }; let data: ClawdHubSkillsResponse = match response.json().await { Ok(parsed) => parsed, Err(err) => { if page > 0 && !all_items.is_empty() { break; } return Err(err.into()); } }; for item in data.items { let latest_version = item .tags .as_ref() .and_then(|t| t.latest.clone()) .or_else(|| item.latest_version.as_ref().and_then(|v| v.version.clone())) .unwrap_or_else(|| "1.0.0".to_string()); all_items.push(SkillsCatalogItem { source_id: "clawdhub".to_string(), repo_source: "clawdhub:registry".to_string(), repo_subpath: None, git_identity_id: None, skill_dir: item.slug.clone(), skill_name: item.slug.clone(), frontmatter_name: item.display_name.clone(), description: item.summary, installable: true, warnings: None, installed: SkillsCatalogInstalledBadge { is_installed: false, scope: None, }, clawdhub: Some(ClawdHubSkillMetadata { slug: item.slug, version: latest_version, display_name: item.display_name, owner: item.owner.and_then(|o| o.handle), downloads: item.stats.as_ref().and_then(|s| s.downloads), stars: item.stats.as_ref().and_then(|s| s.stars), }), }); } match data.next_cursor { Some(c) => cursor = Some(c), None => break, } // Rate limiting tokio::time::sleep(Duration::from_millis(100)).await; } // Sort by downloads (most popular first) all_items.sort_by(|a, b| { let a_downloads = a.clawdhub.as_ref().and_then(|c| c.downloads).unwrap_or(0); let b_downloads = b.clawdhub.as_ref().and_then(|c| c.downloads).unwrap_or(0); b_downloads.cmp(&a_downloads) }); Ok(all_items) } async fn download_clawdhub_skill(slug: &str, version: &str) -> Result> { let client = reqwest::Client::builder() .user_agent("OpenChamber-Desktop/1.0") .timeout(Duration::from_secs(60)) .build()?; let url = format!( "{}/download?slug={}&version={}", CLAWDHUB_API_BASE, urlencoding::encode(slug), urlencoding::encode(version) ); let response = client.get(&url).send().await?; if !response.status().is_success() { return Err(anyhow!("ClawdHub download error: {}", response.status())); } Ok(response.bytes().await?.to_vec()) } async fn fetch_clawdhub_skill_info(slug: &str) -> Result { let client = reqwest::Client::builder() .user_agent("OpenChamber-Desktop/1.0") .timeout(Duration::from_secs(15)) .build()?; let url = format!("{}/skills/{}", CLAWDHUB_API_BASE, urlencoding::encode(slug)); let response = client.get(&url).send().await?; if !response.status().is_success() { return Err(anyhow!("ClawdHub skill info error: {}", response.status())); } Ok(response.json().await?) } fn load_custom_catalog_sources() -> Vec { let settings_path = dirs::home_dir().map(|mut home| { home.push(".config"); home.push("openchamber"); home.push("settings.json"); home }); let Some(path) = settings_path else { return vec![]; }; let Ok(content) = std::fs::read_to_string(path) else { return vec![]; }; let Ok(value) = serde_json::from_str::(&content) else { return vec![]; }; let Some(arr) = value.get("skillCatalogs").and_then(|v| v.as_array()) else { return vec![]; }; let mut result = vec![]; let mut seen = std::collections::HashSet::new(); for entry in arr { let Some(obj) = entry.as_object() else { continue; }; let id = obj.get("id").and_then(|v| v.as_str()).unwrap_or("").trim(); let label = obj .get("label") .and_then(|v| v.as_str()) .unwrap_or("") .trim(); let source = obj .get("source") .and_then(|v| v.as_str()) .unwrap_or("") .trim(); let subpath = obj .get("subpath") .and_then(|v| v.as_str()) .unwrap_or("") .trim(); let git_identity_id = obj .get("gitIdentityId") .and_then(|v| v.as_str()) .unwrap_or("") .trim(); if id.is_empty() || label.is_empty() || source.is_empty() { continue; } if seen.contains(id) { continue; } seen.insert(id.to_string()); result.push(SkillsCatalogSource { id: id.to_string(), label: label.to_string(), description: Some(source.to_string()), source: source.to_string(), default_subpath: if subpath.is_empty() { None } else { Some(subpath.to_string()) }, git_identity_id: if git_identity_id.is_empty() { None } else { Some(git_identity_id.to_string()) }, }); } result } pub async fn get_curated_sources() -> Vec { let mut sources = vec![ SkillsCatalogSource { id: "anthropic".to_string(), label: "Anthropic".to_string(), description: Some("Anthropic's public skills repository".to_string()), source: "anthropics/skills".to_string(), default_subpath: Some("skills".to_string()), git_identity_id: None, }, SkillsCatalogSource { id: "clawdhub".to_string(), label: "ClawdHub".to_string(), description: Some("Community skill registry with vector search".to_string()), source: "clawdhub:registry".to_string(), default_subpath: None, git_identity_id: None, }, ]; sources.extend(load_custom_catalog_sources()); sources } pub async fn get_catalog(working_directory: &Path, refresh: bool) -> SkillsCatalogResponse { let sources = get_curated_sources().await; let discovered = opencode_config::discover_skills(Some(working_directory)); let installed_by_name: HashMap = discovered .into_iter() .map(|s| (s.name.clone(), s)) .collect(); let mut items_by_source: HashMap> = HashMap::new(); for src in &sources { // Handle ClawdHub sources separately (API-based, not git-based) if is_clawdhub_source(&src.source) { let key = "clawdhub:registry".to_string(); let maybe_cached = if refresh { None } else { let cache = CATALOG_CACHE.lock().await; cache.get(&key).cloned() }; let cached_items = maybe_cached.and_then(|entry| { if entry.created_at.elapsed() < CACHE_TTL { Some(entry.items) } else { None } }); let scanned_items = if let Some(items) = cached_items { items } else { let items = match scan_clawdhub().await { Ok(items) => items, Err(_) => { items_by_source.insert(src.id.clone(), vec![]); continue; } }; let mut cache = CATALOG_CACHE.lock().await; cache.insert( key, CacheEntry { created_at: Instant::now(), items: items.clone(), }, ); items }; // Update installed badges let enriched: Vec = scanned_items .into_iter() .map(|mut item| { let installed = installed_by_name.get(&item.skill_name); item.installed = SkillsCatalogInstalledBadge { is_installed: installed.is_some(), scope: installed.map(|s| match s.scope { opencode_config::Scope::User => "user".to_string(), opencode_config::Scope::Project => "project".to_string(), }), }; item }) .collect(); items_by_source.insert(src.id.clone(), enriched); continue; } // Handle GitHub sources (git clone based) let parsed = match parse_repo_source(&src.source, None) { Ok(p) => p, Err(_) => { items_by_source.insert(src.id.clone(), vec![]); continue; } }; let effective_subpath = src .default_subpath .as_deref() .or(parsed.effective_subpath.as_deref()) .unwrap_or(""); let key = cache_key( &parsed.normalized_repo, Some(effective_subpath), src.git_identity_id.as_deref(), ); let maybe_cached = if refresh { None } else { let cache = CATALOG_CACHE.lock().await; cache.get(&key).cloned() }; let cached_items = maybe_cached.and_then(|entry| { if entry.created_at.elapsed() < CACHE_TTL { Some(entry.items) } else { None } }); let scanned_items = if let Some(items) = cached_items { items } else { let ssh_key = resolve_identity_ssh_key(src.git_identity_id.as_deref()); let scan = scan_repo_items( &src.source, None, src.default_subpath.as_deref(), ssh_key.as_deref(), ) .await; let (_, _, raw_items) = match scan { Ok(v) => v, Err(_) => { items_by_source.insert(src.id.clone(), vec![]); continue; } }; let mut items: Vec = vec![]; for (repo_source, skill_dir, fm_name, desc, warnings, installable) in raw_items { let skill_name = skill_dir .split('/') .filter(|s| !s.is_empty()) .last() .unwrap_or("") .to_string(); let installed = installed_by_name.get(&skill_name); items.push(SkillsCatalogItem { source_id: src.id.clone(), repo_source, repo_subpath: src.default_subpath.clone(), git_identity_id: src.git_identity_id.clone(), skill_dir, skill_name, frontmatter_name: fm_name, description: desc, installable, warnings: if warnings.is_empty() { None } else { Some(warnings) }, installed: SkillsCatalogInstalledBadge { is_installed: installed.is_some(), scope: installed.map(|s| match s.scope { opencode_config::Scope::User => "user".to_string(), opencode_config::Scope::Project => "project".to_string(), }), }, clawdhub: None, }); } items.sort_by(|a, b| a.skill_name.cmp(&b.skill_name)); let mut cache = CATALOG_CACHE.lock().await; cache.insert( key, CacheEntry { created_at: Instant::now(), items: items.clone(), }, ); items }; // Update installed badges at request time (cache may be stale for installs) let mut enriched = vec![]; for mut item in scanned_items { let installed = installed_by_name.get(&item.skill_name); item.installed = SkillsCatalogInstalledBadge { is_installed: installed.is_some(), scope: installed.map(|s| match s.scope { opencode_config::Scope::User => "user".to_string(), opencode_config::Scope::Project => "project".to_string(), }), }; enriched.push(item); } items_by_source.insert(src.id.clone(), enriched); } SkillsCatalogResponse { ok: true, sources: Some(sources), items_by_source: Some(items_by_source), error: None, } } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SkillsScanRequest { pub source: String, pub subpath: Option, pub git_identity_id: Option, } pub async fn scan_repository(req: SkillsScanRequest) -> SkillsRepoScanResponse { let ssh_key = resolve_identity_ssh_key(req.git_identity_id.as_deref()); match scan_repo_items( &req.source, req.subpath.as_deref(), None, ssh_key.as_deref(), ) .await { Ok((_normalized, effective_subpath, raw_items)) => { let mut items = vec![]; for (repo_source, skill_dir, fm_name, desc, warnings, installable) in raw_items { let skill_name = skill_dir .split('/') .filter(|s| !s.is_empty()) .last() .unwrap_or("") .to_string(); items.push(SkillsCatalogItem { source_id: "manual".to_string(), repo_source, repo_subpath: effective_subpath.clone(), git_identity_id: req.git_identity_id.clone(), skill_dir, skill_name, frontmatter_name: fm_name, description: desc, installable, warnings: if warnings.is_empty() { None } else { Some(warnings) }, installed: SkillsCatalogInstalledBadge { is_installed: false, scope: None, }, clawdhub: None, }); } items.sort_by(|a, b| a.skill_name.cmp(&b.skill_name)); SkillsRepoScanResponse { ok: true, items: Some(items), error: None, } } Err(err) => { if err.to_string().contains("AUTH_REQUIRED") { return SkillsRepoScanResponse { ok: false, items: None, error: Some(auth_required_error( "Authentication required to access this repository", )), }; } SkillsRepoScanResponse { ok: false, items: None, error: Some(simple_error("networkError", &err.to_string())), } } } } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ClawdHubInstallMeta { pub slug: String, pub version: String, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SkillsInstallSelection { pub skill_dir: String, pub clawdhub: Option, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SkillsInstallRequest { pub source: String, pub subpath: Option, pub git_identity_id: Option, pub scope: String, pub selections: Vec, pub conflict_policy: Option, pub conflict_decisions: Option>, } fn user_skill_dir() -> Result { Ok(dirs::home_dir() .ok_or_else(|| anyhow!("Could not find home directory"))? .join(".config") .join("opencode") .join("skills")) } fn legacy_user_skill_dir() -> Result { Ok(dirs::home_dir() .ok_or_else(|| anyhow!("Could not find home directory"))? .join(".config") .join("opencode") .join("skill")) } fn target_skill_dir(scope: &str, working_directory: &Path, skill_name: &str) -> Result { if scope == "user" { let preferred = user_skill_dir()?.join(skill_name); let legacy = legacy_user_skill_dir()?.join(skill_name); if legacy.exists() && !preferred.exists() { return Ok(legacy); } return Ok(preferred); } if scope == "project" { let preferred = working_directory .join(".opencode") .join("skills") .join(skill_name); let legacy = working_directory .join(".opencode") .join("skill") .join(skill_name); if legacy.exists() && !preferred.exists() { return Ok(legacy); } return Ok(preferred); } Err(anyhow!("Invalid scope")) } fn repo_path_to_fs(base: &Path, repo_rel_posix: &str) -> PathBuf { let mut current = base.to_path_buf(); for part in repo_rel_posix.split('/') { let trimmed = part.trim(); if trimmed.is_empty() { continue; } current.push(trimmed); } current } async fn copy_dir_no_symlinks(src: &Path, dst: &Path) -> Result<()> { let src_real = tokio::fs::canonicalize(src).await?; tokio::fs::create_dir_all(dst).await?; let mut stack: Vec<(PathBuf, PathBuf)> = vec![(src.to_path_buf(), dst.to_path_buf())]; while let Some((current_src, current_dst)) = stack.pop() { tokio::fs::create_dir_all(¤t_dst).await?; let current_src_real = tokio::fs::canonicalize(¤t_src).await?; if !current_src_real.starts_with(&src_real) { return Err(anyhow!("Invalid source path traversal detected")); } let mut dir = tokio::fs::read_dir(¤t_src).await?; while let Some(entry) = dir.next_entry().await? { let next_src = entry.path(); let next_dst = current_dst.join(entry.file_name()); let meta = tokio::fs::symlink_metadata(&next_src).await?; if meta.file_type().is_symlink() { return Err(anyhow!("Symlinks are not supported in skills")); } if meta.is_dir() { stack.push((next_src, next_dst)); continue; } if meta.is_file() { if let Some(parent) = next_dst.parent() { tokio::fs::create_dir_all(parent).await?; } tokio::fs::copy(&next_src, &next_dst).await?; #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; let mode = meta.permissions().mode() & 0o777; let mut perms = tokio::fs::metadata(&next_dst).await?.permissions(); perms.set_mode(mode); let _ = tokio::fs::set_permissions(&next_dst, perms).await; } } } } Ok(()) } async fn install_skills_from_clawdhub( working_directory: &Path, req: &SkillsInstallRequest, ) -> SkillsInstallResponse { let mut installed = vec![]; let mut skipped = vec![]; let _user_dir = match user_skill_dir() { Ok(d) => d, Err(e) => { return SkillsInstallResponse { ok: false, installed: None, skipped: None, error: Some(simple_error("unknown", &e.to_string())), }; } }; // Check for conflicts first let mut conflicts = vec![]; for sel in &req.selections { let slug = sel .clawdhub .as_ref() .map(|c| c.slug.as_str()) .unwrap_or(&sel.skill_dir); if !validate_skill_name(slug) { continue; } let target = match target_skill_dir(&req.scope, working_directory, slug) { Ok(p) => p, Err(_) => continue, }; if target.exists() { let decision = req .conflict_decisions .as_ref() .and_then(|m| m.get(slug)) .map(|s| s.as_str()); let auto = req.conflict_policy.as_deref().unwrap_or("prompt"); if decision.is_none() && auto != "skipAll" && auto != "overwriteAll" { conflicts.push(SkillConflict { skill_name: slug.to_string(), scope: req.scope.clone(), }); } } } if !conflicts.is_empty() { return SkillsInstallResponse { ok: false, installed: None, skipped: None, error: Some(conflicts_error(conflicts)), }; } for sel in &req.selections { let slug = sel .clawdhub .as_ref() .map(|c| c.slug.as_str()) .unwrap_or(&sel.skill_dir); let mut version = sel .clawdhub .as_ref() .map(|c| c.version.as_str()) .unwrap_or("latest") .to_string(); if !validate_skill_name(slug) { skipped.push(SkippedSkill { skill_name: slug.to_string(), reason: "Invalid skill name".to_string(), }); continue; } // Resolve 'latest' version if version == "latest" { if let Ok(info) = fetch_clawdhub_skill_info(slug).await { if let Some(latest) = info .skill .and_then(|s| s.tags) .and_then(|t| t.latest) .or_else(|| info.latest_version.and_then(|v| v.version)) { version = latest; } } if version == "latest" { skipped.push(SkippedSkill { skill_name: slug.to_string(), reason: "Unable to resolve latest version".to_string(), }); continue; } } let target_dir = match target_skill_dir(&req.scope, working_directory, slug) { Ok(p) => p, Err(e) => { skipped.push(SkippedSkill { skill_name: slug.to_string(), reason: e.to_string(), }); continue; } }; let exists = target_dir.exists(); let mut decision = req .conflict_decisions .as_ref() .and_then(|m| m.get(slug)) .cloned(); let auto = req.conflict_policy.as_deref().unwrap_or("prompt"); if decision.is_none() { if exists && auto == "skipAll" { decision = Some("skip".to_string()); } if exists && auto == "overwriteAll" { decision = Some("overwrite".to_string()); } if !exists { decision = Some("overwrite".to_string()); } } if exists && decision.as_deref() == Some("skip") { skipped.push(SkippedSkill { skill_name: slug.to_string(), reason: "Already installed (skipped)".to_string(), }); continue; } if exists && decision.as_deref() == Some("overwrite") { let _ = tokio::fs::remove_dir_all(&target_dir).await; } // Download and extract match download_clawdhub_skill(slug, &version).await { Ok(zip_data) => { let temp_dir = std::env::temp_dir().join(format!("clawdhub-{}-{}", slug, Uuid::new_v4())); let _ = tokio::fs::remove_dir_all(&temp_dir).await; // Extract ZIP using the zip crate let cursor = std::io::Cursor::new(&zip_data); let mut archive = match zip::ZipArchive::new(cursor) { Ok(a) => a, Err(e) => { skipped.push(SkippedSkill { skill_name: slug.to_string(), reason: format!("Failed to open ZIP: {}", e), }); continue; } }; if let Err(e) = std::fs::create_dir_all(&temp_dir) { skipped.push(SkippedSkill { skill_name: slug.to_string(), reason: format!("Failed to create temp dir: {}", e), }); continue; } let mut extract_ok = true; for i in 0..archive.len() { let mut file = match archive.by_index(i) { Ok(f) => f, Err(e) => { skipped.push(SkippedSkill { skill_name: slug.to_string(), reason: format!("Failed to read ZIP entry: {}", e), }); extract_ok = false; break; } }; let outpath = temp_dir.join(file.name()); if file.name().ends_with('/') { let _ = std::fs::create_dir_all(&outpath); } else { if let Some(p) = outpath.parent() { let _ = std::fs::create_dir_all(p); } let mut outfile = match std::fs::File::create(&outpath) { Ok(f) => f, Err(e) => { skipped.push(SkippedSkill { skill_name: slug.to_string(), reason: format!("Failed to create file: {}", e), }); extract_ok = false; break; } }; if let Err(e) = std::io::copy(&mut file, &mut outfile) { skipped.push(SkippedSkill { skill_name: slug.to_string(), reason: format!("Failed to write file: {}", e), }); extract_ok = false; break; } } } if !extract_ok { let _ = std::fs::remove_dir_all(&temp_dir); continue; } // Verify SKILL.md exists let skill_md_path = temp_dir.join("SKILL.md"); if !skill_md_path.exists() { skipped.push(SkippedSkill { skill_name: slug.to_string(), reason: "SKILL.md not found in downloaded package".to_string(), }); let _ = std::fs::remove_dir_all(&temp_dir); continue; } // Move to target directory if let Some(parent) = target_dir.parent() { let _ = tokio::fs::create_dir_all(parent).await; } if let Err(e) = tokio::fs::rename(&temp_dir, &target_dir).await { // If rename fails (cross-device), try copy if let Err(e2) = copy_dir_no_symlinks(&temp_dir, &target_dir).await { skipped.push(SkippedSkill { skill_name: slug.to_string(), reason: format!("Failed to move files: {} / {}", e, e2), }); let _ = tokio::fs::remove_dir_all(&temp_dir).await; continue; } let _ = tokio::fs::remove_dir_all(&temp_dir).await; } installed.push(InstalledSkill { skill_name: slug.to_string(), scope: req.scope.clone(), }); } Err(e) => { skipped.push(SkippedSkill { skill_name: slug.to_string(), reason: format!("Failed to download: {}", e), }); } } } SkillsInstallResponse { ok: true, installed: Some(installed), skipped: Some(skipped), error: None, } } pub async fn install_skills( working_directory: &Path, req: SkillsInstallRequest, ) -> SkillsInstallResponse { // Handle ClawdHub sources separately if is_clawdhub_source(&req.source) { return install_skills_from_clawdhub(working_directory, &req).await; } let ssh_key = resolve_identity_ssh_key(req.git_identity_id.as_deref()); let selections: Vec = req .selections .into_iter() .map(|s| s.skill_dir.trim().to_string()) .filter(|s| !s.is_empty()) .collect(); if selections.is_empty() { return SkillsInstallResponse { ok: false, installed: None, skipped: None, error: Some(simple_error( "invalidSource", "No skills selected for installation", )), }; } // Compute conflicts in target scope only. let mut conflicts = vec![]; for skill_dir in &selections { let skill_name = skill_dir .split('/') .filter(|s| !s.is_empty()) .last() .unwrap_or("") .to_string(); if !validate_skill_name(&skill_name) { continue; } let target = match target_skill_dir(&req.scope, working_directory, &skill_name) { Ok(p) => p, Err(_) => { return SkillsInstallResponse { ok: false, installed: None, skipped: None, error: Some(simple_error("invalidSource", "Invalid scope")), }; } }; if target.exists() { let decision = req .conflict_decisions .as_ref() .and_then(|m| m.get(&skill_name)) .map(|s| s.as_str()); let auto = req.conflict_policy.as_deref().unwrap_or("prompt"); if decision.is_none() && auto != "skipAll" && auto != "overwriteAll" { conflicts.push(SkillConflict { skill_name, scope: req.scope.clone(), }); } } } if !conflicts.is_empty() { return SkillsInstallResponse { ok: false, installed: None, skipped: None, error: Some(conflicts_error(conflicts)), }; } // Clone let parsed = match parse_repo_source(&req.source, req.subpath.as_deref()) { Ok(p) => p, Err(err) => { return SkillsInstallResponse { ok: false, installed: None, skipped: None, error: Some(simple_error("invalidSource", &err.to_string())), }; } }; let clone_url = if ssh_key.is_some() { parsed.clone_ssh.clone() } else { parsed.clone_https.clone() }; let temp_base = std::env::temp_dir().join(format!( "openchamber-desktop-skills-install-{}", Uuid::new_v4() )); let _ = tokio::fs::remove_dir_all(&temp_base).await; let clone_res = clone_repo(&clone_url, &temp_base, ssh_key.as_deref()).await; if let Err(err) = clone_res { let msg = err.to_string(); if AUTH_ERROR_RE.is_match(&msg) { return SkillsInstallResponse { ok: false, installed: None, skipped: None, error: Some(auth_required_error( "Authentication required to access this repository", )), }; } return SkillsInstallResponse { ok: false, installed: None, skipped: None, error: Some(simple_error("networkError", &msg)), }; } // sparse-checkout selected dirs let init_args = vec![ "-C".to_string(), temp_base.display().to_string(), "sparse-checkout".to_string(), "init".to_string(), "--cone".to_string(), ]; let _ = run_git( &init_args, &std::env::temp_dir(), ssh_key.as_deref(), Duration::from_secs(15), ) .await; let mut set_args = vec![ "-C".to_string(), temp_base.display().to_string(), "sparse-checkout".to_string(), "set".to_string(), ]; for dir in &selections { set_args.push(dir.clone()); } if let Err(err) = run_git( &set_args, &std::env::temp_dir(), ssh_key.as_deref(), Duration::from_secs(30), ) .await { safe_rm(&temp_base).await; return SkillsInstallResponse { ok: false, installed: None, skipped: None, error: Some(simple_error("unknown", &err.to_string())), }; } let checkout_args = vec![ "-C".to_string(), temp_base.display().to_string(), "checkout".to_string(), "--force".to_string(), "HEAD".to_string(), ]; if let Err(err) = run_git( &checkout_args, &std::env::temp_dir(), ssh_key.as_deref(), Duration::from_secs(60), ) .await { safe_rm(&temp_base).await; return SkillsInstallResponse { ok: false, installed: None, skipped: None, error: Some(simple_error("unknown", &err.to_string())), }; } let mut installed = vec![]; let mut skipped = vec![]; for skill_dir in selections { let skill_name = skill_dir .split('/') .filter(|s| !s.is_empty()) .last() .unwrap_or("") .to_string(); if !validate_skill_name(&skill_name) { skipped.push(SkippedSkill { skill_name, reason: "Invalid skill name (directory basename)".to_string(), }); continue; } let src_dir = repo_path_to_fs(&temp_base, &skill_dir); let skill_md = src_dir.join("SKILL.md"); if !skill_md.exists() { skipped.push(SkippedSkill { skill_name, reason: "SKILL.md not found in selected directory".to_string(), }); continue; } let target_dir = match target_skill_dir(&req.scope, working_directory, &skill_name) { Ok(p) => p, Err(err) => { skipped.push(SkippedSkill { skill_name, reason: err.to_string(), }); continue; } }; let exists = target_dir.exists(); let mut decision: Option = req .conflict_decisions .as_ref() .and_then(|m| m.get(&skill_name)) .cloned(); let auto = req .conflict_policy .as_deref() .unwrap_or("prompt") .to_string(); if decision.is_none() { if exists && auto == "skipAll" { decision = Some("skip".to_string()); } if exists && auto == "overwriteAll" { decision = Some("overwrite".to_string()); } if !exists { decision = Some("overwrite".to_string()); } } if exists && decision.as_deref() == Some("skip") { skipped.push(SkippedSkill { skill_name, reason: "Already installed (skipped)".to_string(), }); continue; } if exists && decision.as_deref() == Some("overwrite") { let _ = tokio::fs::remove_dir_all(&target_dir).await; } if let Some(parent) = target_dir.parent() { let _ = tokio::fs::create_dir_all(parent).await; } if let Err(err) = copy_dir_no_symlinks(&src_dir, &target_dir).await { let _ = tokio::fs::remove_dir_all(&target_dir).await; skipped.push(SkippedSkill { skill_name, reason: err.to_string(), }); continue; } installed.push(InstalledSkill { skill_name, scope: req.scope.clone(), }); } safe_rm(&temp_base).await; SkillsInstallResponse { ok: true, installed: Some(installed), skipped: Some(skipped), error: None, } }