Files
openchamber/packages/desktop/src-tauri/src/skills_catalog.rs
T
Bohdan Triapitsyn 25525d5b1b feat: enhance ClawdHub integration with paging and retry
Add paging support when loading ClawdHub skills
Retry on API errors and throttle for ClawdHub requests
Load source content on source change and show loading indicators
2026-01-26 19:50:11 +02:00

2042 lines
61 KiB
Rust

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<Regex> =
Lazy::new(|| Regex::new(r"^[a-z0-9]([a-z0-9-]*[a-z0-9])?$").expect("valid skill name regex"));
static AUTH_ERROR_RE: Lazy<Regex> = 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<String>,
pub source: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub default_subpath: Option<String>,
#[serde(skip_serializing)]
pub git_identity_id: Option<String>,
}
#[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<String>,
}
#[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<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub owner: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub downloads: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub stars: Option<u64>,
}
#[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<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub git_identity_id: Option<String>,
pub skill_dir: String,
pub skill_name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub frontmatter_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
pub installable: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub warnings: Option<Vec<String>>,
pub installed: SkillsCatalogInstalledBadge,
#[serde(skip_serializing_if = "Option::is_none")]
pub clawdhub: Option<ClawdHubSkillMetadata>,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SkillsCatalogResponse {
pub ok: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub sources: Option<Vec<SkillsCatalogSource>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub items_by_source: Option<HashMap<String, Vec<SkillsCatalogItem>>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<SkillsRepoError>,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SkillsRepoScanResponse {
pub ok: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub items: Option<Vec<SkillsCatalogItem>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<SkillsRepoError>,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SkillsInstallResponse {
pub ok: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub installed: Option<Vec<InstalledSkill>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub skipped: Option<Vec<SkippedSkill>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<SkillsRepoError>,
}
#[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<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub identities: Option<Vec<IdentitySummary>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub conflicts: Option<Vec<SkillConflict>>,
}
#[derive(Debug, Clone)]
struct RepoParsed {
normalized_repo: String,
clone_https: String,
clone_ssh: String,
effective_subpath: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
struct GitIdentityWrapper {
profiles: Vec<GitIdentityProfile>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
struct GitIdentityProfile {
id: String,
name: String,
#[serde(default)]
ssh_key: Option<String>,
}
fn identities_storage_path() -> Result<PathBuf> {
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<IdentitySummary> {
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::<GitIdentityWrapper>(&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<String> {
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::<GitIdentityWrapper>(&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<RepoParsed> {
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<String>, Option<String>, Vec<String>) {
// 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<SkillConflict>) -> 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<String>,
Vec<(
String,
String,
Option<String>,
Option<String>,
Vec<String>,
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<String> = 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<String> = 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<String> = 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<SkillsCatalogItem>,
}
static CATALOG_CACHE: Lazy<Mutex<HashMap<String, CacheEntry>>> =
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<String>,
}
#[derive(Debug, Deserialize)]
struct ClawdHubSkillStats {
downloads: Option<u64>,
stars: Option<u64>,
}
#[derive(Debug, Deserialize)]
struct ClawdHubSkillTags {
latest: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct ClawdHubSkillVersion {
version: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct ClawdHubSkillListItem {
slug: String,
display_name: Option<String>,
summary: Option<String>,
tags: Option<ClawdHubSkillTags>,
latest_version: Option<ClawdHubSkillVersion>,
stats: Option<ClawdHubSkillStats>,
owner: Option<ClawdHubSkillOwner>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct ClawdHubSkillsResponse {
items: Vec<ClawdHubSkillListItem>,
next_cursor: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct ClawdHubSkillInfoResponse {
skill: Option<ClawdHubSkillInfoSkill>,
latest_version: Option<ClawdHubSkillVersion>,
}
#[derive(Debug, Deserialize)]
struct ClawdHubSkillInfoSkill {
tags: Option<ClawdHubSkillTags>,
}
async fn scan_clawdhub() -> Result<Vec<SkillsCatalogItem>> {
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<String> = 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<reqwest::Response> = 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<Vec<u8>> {
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<ClawdHubSkillInfoResponse> {
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<SkillsCatalogSource> {
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::<serde_json::Value>(&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<SkillsCatalogSource> {
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<String, opencode_config::DiscoveredSkill> = discovered
.into_iter()
.map(|s| (s.name.clone(), s))
.collect();
let mut items_by_source: HashMap<String, Vec<SkillsCatalogItem>> = 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<SkillsCatalogItem> = 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<SkillsCatalogItem> = 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<String>,
pub git_identity_id: Option<String>,
}
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<ClawdHubInstallMeta>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SkillsInstallRequest {
pub source: String,
pub subpath: Option<String>,
pub git_identity_id: Option<String>,
pub scope: String,
pub selections: Vec<SkillsInstallSelection>,
pub conflict_policy: Option<String>,
pub conflict_decisions: Option<HashMap<String, String>>,
}
fn user_skill_dir() -> Result<PathBuf> {
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<PathBuf> {
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<PathBuf> {
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(&current_dst).await?;
let current_src_real = tokio::fs::canonicalize(&current_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(&current_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<String> = 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<String> = 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,
}
}