diff --git a/packages/desktop/src-tauri/src/commands/settings.rs b/packages/desktop/src-tauri/src/commands/settings.rs index 3fd6ef19..fbc7dd9b 100644 --- a/packages/desktop/src-tauri/src/commands/settings.rs +++ b/packages/desktop/src-tauri/src/commands/settings.rs @@ -191,6 +191,48 @@ fn sanitize_settings_update(payload: &Value) -> Value { result_obj.insert("typographySizes".to_string(), sanitized); } } + + // Skill catalogs (array of objects) + if let Some(Value::Array(arr)) = obj.get("skillCatalogs") { + let mut seen: HashSet = HashSet::new(); + let mut catalogs: Vec = vec![]; + + 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()); + + let mut catalog = serde_json::Map::new(); + catalog.insert("id".to_string(), json!(id)); + catalog.insert("label".to_string(), json!(label)); + catalog.insert("source".to_string(), json!(source)); + if !subpath.is_empty() { + catalog.insert("subpath".to_string(), json!(subpath)); + } + if !git_identity_id.is_empty() { + catalog.insert("gitIdentityId".to_string(), json!(git_identity_id)); + } + + catalogs.push(Value::Object(catalog)); + } + + if !catalogs.is_empty() { + result_obj.insert("skillCatalogs".to_string(), Value::Array(catalogs)); + } + } } result diff --git a/packages/desktop/src-tauri/src/main.rs b/packages/desktop/src-tauri/src/main.rs index a42f3543..d74dff80 100644 --- a/packages/desktop/src-tauri/src/main.rs +++ b/packages/desktop/src-tauri/src/main.rs @@ -9,6 +9,7 @@ mod opencode_config; mod opencode_manager; mod window_state; mod path_utils; +mod skills_catalog; use std::{collections::HashMap, path::PathBuf, sync::Arc, time::{Duration, Instant}}; @@ -1797,6 +1798,102 @@ async fn handle_config_routes( return handle_command_route(&state, method, req, trimmed.to_string()).await; } + // Skills catalog routes (must be checked before /api/config/skills/:name) + if path == "/api/config/skills/catalog" && method == Method::GET { + let refresh = req + .uri() + .query() + .map(|q| q.contains("refresh=true")) + .unwrap_or(false); + + let working_directory = state.opencode.get_working_directory(); + let payload = skills_catalog::get_catalog(&working_directory, refresh).await; + return Ok(json_response(StatusCode::OK, payload)); + } + + if path == "/api/config/skills/scan" && method == Method::POST { + let payload_map = match parse_request_payload(req).await { + Ok(data) => data, + Err(resp) => return Ok(resp), + }; + + let payload_value = serde_json::Value::Object(payload_map.into_iter().collect()); + let scan_request = match serde_json::from_value::(payload_value) { + Ok(v) => v, + Err(_) => { + return Ok(json_response( + StatusCode::BAD_REQUEST, + skills_catalog::SkillsRepoScanResponse { + ok: false, + items: None, + error: Some(skills_catalog::SkillsRepoError { + kind: "invalidSource".to_string(), + message: "Malformed scan request".to_string(), + ssh_only: None, + identities: None, + conflicts: None, + }), + }, + )) + } + }; + + let response = skills_catalog::scan_repository(scan_request).await; + let status = if response.ok { + StatusCode::OK + } else if response.error.as_ref().map(|e| e.kind.as_str()) == Some("authRequired") { + StatusCode::UNAUTHORIZED + } else { + StatusCode::BAD_REQUEST + }; + + return Ok(json_response(status, response)); + } + + if path == "/api/config/skills/install" && method == Method::POST { + let payload_map = match parse_request_payload(req).await { + Ok(data) => data, + Err(resp) => return Ok(resp), + }; + + let payload_value = serde_json::Value::Object(payload_map.into_iter().collect()); + let install_request = match serde_json::from_value::(payload_value) { + Ok(v) => v, + Err(_) => { + return Ok(json_response( + StatusCode::BAD_REQUEST, + skills_catalog::SkillsInstallResponse { + ok: false, + installed: None, + skipped: None, + error: Some(skills_catalog::SkillsRepoError { + kind: "invalidSource".to_string(), + message: "Malformed install request".to_string(), + ssh_only: None, + identities: None, + conflicts: None, + }), + }, + )) + } + }; + + let working_directory = state.opencode.get_working_directory(); + let response = skills_catalog::install_skills(&working_directory, install_request).await; + + let status = if response.ok { + StatusCode::OK + } else if response.error.as_ref().map(|e| e.kind.as_str()) == Some("conflicts") { + StatusCode::CONFLICT + } else if response.error.as_ref().map(|e| e.kind.as_str()) == Some("authRequired") { + StatusCode::UNAUTHORIZED + } else { + StatusCode::BAD_REQUEST + }; + + return Ok(json_response(status, response)); + } + // Handle skill routes: /api/config/skills and /api/config/skills/:name if path == "/api/config/skills" && method == Method::GET { return handle_skill_list_route(&state).await; diff --git a/packages/desktop/src-tauri/src/skills_catalog.rs b/packages/desktop/src-tauri/src/skills_catalog.rs new file mode 100644 index 00000000..d4f35a36 --- /dev/null +++ b/packages/desktop/src-tauri/src/skills_catalog.rs @@ -0,0 +1,1266 @@ +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 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, +} + +#[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("") + ) +} + +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, + }]; + + 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 { + 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(), + }), + }, + }); + } + + 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 }, + }); + } + 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 SkillsInstallSelection { + pub skill_dir: String, +} + +#[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("skill")) +} + +fn target_skill_dir(scope: &str, working_directory: &Path, skill_name: &str) -> Result { + if scope == "user" { + return Ok(user_skill_dir()?.join(skill_name)); + } + + if scope == "project" { + return Ok(working_directory.join(".opencode").join("skill").join(skill_name)); + } + + 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(()) +} + +pub async fn install_skills(working_directory: &Path, req: SkillsInstallRequest) -> SkillsInstallResponse { + 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, + } +} diff --git a/packages/ui/src/components/sections/agents/AgentsSidebar.tsx b/packages/ui/src/components/sections/agents/AgentsSidebar.tsx index 94b17bde..a7e6d457 100644 --- a/packages/ui/src/components/sections/agents/AgentsSidebar.tsx +++ b/packages/ui/src/components/sections/agents/AgentsSidebar.tsx @@ -82,6 +82,7 @@ export const AgentsSidebar: React.FC = ({ onItemSelect }) => // Set draft and open the page for editing setAgentDraft({ name: newName, scope: 'user' }); setSelectedAgent(newName); + onItemSelect?.(); if (isMobile) { setSidebarOpen(false); diff --git a/packages/ui/src/components/sections/commands/CommandsSidebar.tsx b/packages/ui/src/components/sections/commands/CommandsSidebar.tsx index 1b49f4a9..6c73a0e5 100644 --- a/packages/ui/src/components/sections/commands/CommandsSidebar.tsx +++ b/packages/ui/src/components/sections/commands/CommandsSidebar.tsx @@ -81,6 +81,7 @@ export const CommandsSidebar: React.FC = ({ onItemSelect } // Set draft and open the page for editing setCommandDraft({ name: newName, scope: 'user' }); setSelectedCommand(newName); + onItemSelect?.(); if (isMobile) { setSidebarOpen(false); diff --git a/packages/ui/src/components/sections/skills/SkillsPage.tsx b/packages/ui/src/components/sections/skills/SkillsPage.tsx index d6a1b2e6..9e71419c 100644 --- a/packages/ui/src/components/sections/skills/SkillsPage.tsx +++ b/packages/ui/src/components/sections/skills/SkillsPage.tsx @@ -21,6 +21,8 @@ import { DialogTitle, } from '@/components/ui/dialog'; import { ButtonLarge } from '@/components/ui/button-large'; +import { AnimatedTabs } from '@/components/ui/animated-tabs'; +import { SkillsCatalogPage } from './catalog/SkillsCatalogPage'; export const SkillsPage: React.FC = () => { const { @@ -37,6 +39,37 @@ export const SkillsPage: React.FC = () => { const selectedSkill = selectedSkillName ? getSkillByName(selectedSkillName) : null; const isNewSkill = Boolean(skillDraft && skillDraft.name === selectedSkillName && !selectedSkill); + const hasStaleSelection = Boolean(selectedSkillName && !selectedSkill && !skillDraft); + + type SkillsMode = 'manual' | 'external'; + const [mode, setMode] = React.useState('manual'); + + React.useEffect(() => { + if (!isNewSkill && mode !== 'manual') { + setMode('manual'); + } + }, [isNewSkill, mode]); + + React.useEffect(() => { + if (!hasStaleSelection) { + return; + } + + // Clear persisted selection if it points to a non-existent skill. + setSelectedSkill(null); + }, [hasStaleSelection, setSelectedSkill]); + + const modeTabs = isNewSkill ? ( + + ) : null; const [draftName, setDraftName] = React.useState(''); const [draftScope, setDraftScope] = React.useState('user'); @@ -71,6 +104,10 @@ export const SkillsPage: React.FC = () => { // Load skill details when selection changes React.useEffect(() => { + if (mode === 'external') { + return; + } + const loadSkillDetails = async () => { if (isNewSkill && skillDraft) { // Prefill from draft (for new or duplicated skills) @@ -104,7 +141,7 @@ export const SkillsPage: React.FC = () => { }; loadSkillDetails(); - }, [selectedSkill, isNewSkill, selectedSkillName, skills, skillDraft, getSkillDetail]); + }, [selectedSkill, isNewSkill, selectedSkillName, skills, skillDraft, getSkillDetail, mode]); const handleSave = async () => { const skillName = isNewSkill ? draftName.trim().replace(/\s+/g, '-').toLowerCase() : selectedSkillName?.trim(); @@ -297,8 +334,13 @@ export const SkillsPage: React.FC = () => { } }; - // Show empty state only when nothing is selected AND no draft - if (!selectedSkillName && !skillDraft) { + if (isNewSkill && mode === 'external') { + return ; + } + + + // Show empty state when nothing is selected or selection is stale + if ((!selectedSkillName && !skillDraft) || hasStaleSelection) { return (
@@ -322,6 +364,8 @@ export const SkillsPage: React.FC = () => { return ( + {isNewSkill ? modeTabs : null} + {/* Header */}

diff --git a/packages/ui/src/components/sections/skills/SkillsSidebar.tsx b/packages/ui/src/components/sections/skills/SkillsSidebar.tsx index 6b3436d7..4d69b026 100644 --- a/packages/ui/src/components/sections/skills/SkillsSidebar.tsx +++ b/packages/ui/src/components/sections/skills/SkillsSidebar.tsx @@ -82,6 +82,7 @@ export const SkillsSidebar: React.FC = ({ onItemSelect }) => // Set draft and open the page for editing setSkillDraft({ name: newName, scope: 'user', description: '' }); setSelectedSkill(newName); + onItemSelect?.(); if (isMobile) { setSidebarOpen(false); diff --git a/packages/ui/src/components/sections/skills/catalog/AddCatalogDialog.tsx b/packages/ui/src/components/sections/skills/catalog/AddCatalogDialog.tsx new file mode 100644 index 00000000..7539cbf1 --- /dev/null +++ b/packages/ui/src/components/sections/skills/catalog/AddCatalogDialog.tsx @@ -0,0 +1,333 @@ +import React from 'react'; +import { toast } from 'sonner'; + +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, +} from '@/components/ui/select'; + +import { RiGitRepositoryLine } from '@remixicon/react'; + +import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry'; +import { getDesktopSettings, isDesktopRuntime, isVSCodeRuntime } from '@/lib/desktop'; +import { updateDesktopSettings } from '@/lib/persistence'; +import type { DesktopSettings, SkillCatalogConfig } from '@/lib/desktop'; +import { useSkillsCatalogStore } from '@/stores/useSkillsCatalogStore'; + +const generateCatalogId = () => `custom:${Date.now()}-${Math.random().toString(16).slice(2)}`; + +const guessLabelFromSource = (value: string) => { + const trimmed = value.trim(); + const ssh = trimmed.match(/^git@github\.com:([^/\s]+)\/([^\s#]+)$/i); + if (ssh) { + return `${ssh[1]}/${ssh[2].replace(/\.git$/i, '')}`; + } + const https = trimmed.match(/^https?:\/\/github\.com\/([^/\s]+)\/([^\s#]+)$/i); + if (https) { + return `${https[1]}/${https[2].replace(/\.git$/i, '')}`; + } + const shorthand = trimmed.match(/^([^/\s]+)\/([^/\s]+)(?:\/.+)?$/); + if (shorthand) { + return `${shorthand[1]}/${shorthand[2].replace(/\.git$/i, '')}`; + } + return trimmed; +}; + +type IdentityOption = { id: string; name: string }; + +const loadSettings = async (): Promise => { + try { + if (isDesktopRuntime()) { + return await getDesktopSettings(); + } + + const runtimeSettings = getRegisteredRuntimeAPIs()?.settings; + if (runtimeSettings) { + const result = await runtimeSettings.load(); + return (result?.settings || {}) as DesktopSettings; + } + + const response = await fetch('/api/config/settings', { + method: 'GET', + headers: { Accept: 'application/json' }, + }); + + if (!response.ok) { + return null; + } + + return (await response.json().catch(() => null)) as DesktopSettings | null; + } catch { + return null; + } +}; + +interface AddCatalogDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export const AddCatalogDialog: React.FC = ({ open, onOpenChange }) => { + const { scanRepo, loadCatalog, isScanning } = useSkillsCatalogStore(); + + const [label, setLabel] = React.useState(''); + const [source, setSource] = React.useState(''); + const [subpath, setSubpath] = React.useState(''); + + const [existingCatalogs, setExistingCatalogs] = React.useState([]); + + const [scanCount, setScanCount] = React.useState(null); + const [scanOk, setScanOk] = React.useState(false); + + const [identityOptions, setIdentityOptions] = React.useState([]); + const [gitIdentityId, setGitIdentityId] = React.useState(null); + + React.useEffect(() => { + if (!open) return; + + setLabel(''); + setSource(''); + setSubpath(''); + setScanCount(null); + setScanOk(false); + setIdentityOptions([]); + setGitIdentityId(null); + + void (async () => { + const settings = await loadSettings(); + const catalogs = Array.isArray(settings?.skillCatalogs) ? settings?.skillCatalogs : []; + setExistingCatalogs(catalogs || []); + })(); + }, [open]); + + const isDuplicate = React.useMemo(() => { + const normalizedSource = source.trim(); + const normalizedSubpath = subpath.trim(); + + return existingCatalogs.some((c) => { + const s = (c.source || '').trim(); + const sp = (c.subpath || '').trim(); + return s === normalizedSource && sp === normalizedSubpath; + }); + }, [existingCatalogs, source, subpath]); + + const handleScan = async () => { + const trimmedSource = source.trim(); + if (!trimmedSource) { + toast.error('Repository source is required'); + return; + } + + if (!label.trim()) { + setLabel(guessLabelFromSource(trimmedSource)); + } + + setScanOk(false); + setScanCount(null); + + const result = await scanRepo({ + source: trimmedSource, + subpath: subpath.trim() || undefined, + gitIdentityId: gitIdentityId || undefined, + }); + + if (!result.ok) { + if (result.error?.kind === 'authRequired') { + if (isVSCodeRuntime()) { + toast.error('Private repositories are not supported in VS Code yet'); + return; + } + + const ids = (result.error.identities || []) as IdentityOption[]; + setIdentityOptions(ids); + if (!gitIdentityId && ids.length > 0) { + setGitIdentityId(ids[0].id); + } + toast.error('Authentication required. Select a Git identity and scan again.'); + return; + } + + toast.error(result.error?.message || 'Failed to scan repository'); + return; + } + + const count = result.items?.length || 0; + setScanCount(count); + if (count === 0) { + toast.error('No skills found in this repository'); + setScanOk(false); + return; + } + + setIdentityOptions([]); + setScanOk(true); + toast.success(`Found ${count} skill(s)`); + }; + + const handleAdd = async () => { + const trimmedLabel = label.trim(); + const trimmedSource = source.trim(); + const trimmedSubpath = subpath.trim(); + + if (!trimmedLabel) { + toast.error('Catalog name is required'); + return; + } + + if (!trimmedSource) { + toast.error('Repository source is required'); + return; + } + + if (!scanOk) { + toast.error('Scan the repository before adding this catalog'); + return; + } + + if (isDuplicate) { + toast.error('This catalog already exists'); + return; + } + + const next: SkillCatalogConfig = { + id: generateCatalogId(), + label: trimmedLabel, + source: trimmedSource, + ...(trimmedSubpath ? { subpath: trimmedSubpath } : {}), + ...(gitIdentityId ? { gitIdentityId } : {}), + }; + + const updated = [...existingCatalogs, next]; + + try { + await updateDesktopSettings({ skillCatalogs: updated }); + setExistingCatalogs(updated); + toast.success('Catalog added'); + await loadCatalog({ refresh: true }); + onOpenChange(false); + } catch (error) { + toast.error(error instanceof Error ? error.message : 'Failed to save catalog'); + } + }; + + return ( + + + + Add skills catalog + + Add a Git repository as a new catalog source. OpenChamber will scan it for folders containing SKILL.md. + + + +
+
+ + setLabel(e.target.value)} placeholder="e.g. Team Skills" /> +
+ +
+ + { + setSource(e.target.value); + setScanOk(false); + setScanCount(null); + }} + placeholder="owner/repo or git@github.com:owner/repo.git" + /> +

+ Public repos work everywhere. Private repos require SSH identity (Desktop/Web only). +

+
+ +
+ + { + setSubpath(e.target.value); + setScanOk(false); + setScanCount(null); + }} + placeholder="e.g. skills" + /> +
+ + {identityOptions.length > 0 && !isVSCodeRuntime() ? ( +
+
Authentication required
+
+ Select a Git identity (SSH key) that can access this repository. +
+
+ +
+
+ Configure identities in Settings → Git Identities. +
+
+ ) : null} + + {scanCount !== null ? ( +
+ Scan result: {scanCount} skill(s) found +
+ ) : null} + + {isDuplicate ? ( +
+ This catalog is already added. +
+ ) : null} +
+ + + + + + +
+
+ ); +}; diff --git a/packages/ui/src/components/sections/skills/catalog/InstallConflictsDialog.tsx b/packages/ui/src/components/sections/skills/catalog/InstallConflictsDialog.tsx new file mode 100644 index 00000000..b53b054a --- /dev/null +++ b/packages/ui/src/components/sections/skills/catalog/InstallConflictsDialog.tsx @@ -0,0 +1,126 @@ +import React from 'react'; + +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { ButtonLarge } from '@/components/ui/button-large'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, +} from '@/components/ui/select'; + +export type SkillConflict = { + skillName: string; + scope: 'user' | 'project'; +}; + +export type ConflictDecision = 'skip' | 'overwrite'; + +interface InstallConflictsDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + conflicts: SkillConflict[]; + onConfirm: (decisions: Record) => void; +} + +export const InstallConflictsDialog: React.FC = ({ + open, + onOpenChange, + conflicts, + onConfirm, +}) => { + const [decisions, setDecisions] = React.useState>({}); + + React.useEffect(() => { + if (!open) return; + const initial: Record = {}; + for (const conflict of conflicts) { + initial[conflict.skillName] = 'skip'; + } + setDecisions(initial); + }, [open, conflicts]); + + const setAll = (decision: ConflictDecision) => { + const next: Record = {}; + for (const conflict of conflicts) { + next[conflict.skillName] = decision; + } + setDecisions(next); + }; + + const canConfirm = conflicts.length > 0 && conflicts.every((c) => decisions[c.skillName]); + + return ( + + + + Skills already exist + + Some selected skills are already installed in this scope. Choose whether to skip or overwrite them. + + + +
+
+ {conflicts.length} conflict(s) +
+ + +
+
+ +
+ {conflicts.map((conflict) => ( +
+
+
{conflict.skillName}
+
Installed in {conflict.scope} scope
+
+ + +
+ ))} +
+
+ + + + onConfirm(decisions)} + disabled={!canConfirm} + > + Continue + + +
+
+ ); +}; diff --git a/packages/ui/src/components/sections/skills/catalog/InstallFromRepoDialog.tsx b/packages/ui/src/components/sections/skills/catalog/InstallFromRepoDialog.tsx new file mode 100644 index 00000000..fcf6c815 --- /dev/null +++ b/packages/ui/src/components/sections/skills/catalog/InstallFromRepoDialog.tsx @@ -0,0 +1,418 @@ +import React from 'react'; +import { toast } from 'sonner'; + +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { ButtonLarge } from '@/components/ui/button-large'; +import { Input } from '@/components/ui/input'; +import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, +} from '@/components/ui/select'; +import { RiFolderLine, RiGitRepositoryLine, RiUser3Line } from '@remixicon/react'; + +import { isVSCodeRuntime } from '@/lib/desktop'; +import type { SkillsCatalogItem } from '@/lib/api/types'; +import { useSkillsCatalogStore } from '@/stores/useSkillsCatalogStore'; +import { useSkillsStore } from '@/stores/useSkillsStore'; +import { InstallConflictsDialog, type ConflictDecision, type SkillConflict } from './InstallConflictsDialog'; + +interface InstallFromRepoDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +type IdentityOption = { id: string; name: string }; + +export const InstallFromRepoDialog: React.FC = ({ open, onOpenChange }) => { + const { scanRepo, installSkills, isScanning, isInstalling } = useSkillsCatalogStore(); + const installedSkills = useSkillsStore((s) => s.skills); + + const [source, setSource] = React.useState(''); + const [subpath, setSubpath] = React.useState(''); + const [scope, setScope] = React.useState<'user' | 'project'>('user'); + + const [items, setItems] = React.useState([]); + const [selected, setSelected] = React.useState>({}); + const [search, setSearch] = React.useState(''); + + const [identities, setIdentities] = React.useState([]); + const [gitIdentityId, setGitIdentityId] = React.useState(null); + + const [conflictsOpen, setConflictsOpen] = React.useState(false); + const [conflicts, setConflicts] = React.useState([]); + const [baseInstallRequest, setBaseInstallRequest] = React.useState<{ + source: string; + subpath?: string; + scope: 'user' | 'project'; + selections: Array<{ skillDir: string }>; + gitIdentityId?: string; + } | null>(null); + + React.useEffect(() => { + if (!open) return; + setSource(''); + setSubpath(''); + setScope('user'); + setItems([]); + setSelected({}); + setSearch(''); + setIdentities([]); + setGitIdentityId(null); + setConflictsOpen(false); + setConflicts([]); + setBaseInstallRequest(null); + }, [open]); + + const installedByName = React.useMemo(() => { + const map = new Map(); + for (const s of installedSkills) { + map.set(s.name, { scope: s.scope }); + } + return map; + }, [installedSkills]); + + const filteredItems = React.useMemo(() => { + const q = search.trim().toLowerCase(); + if (!q) return items; + return items.filter((item) => { + const name = item.skillName.toLowerCase(); + const desc = (item.description || '').toLowerCase(); + const fm = (item.frontmatterName || '').toLowerCase(); + return name.includes(q) || desc.includes(q) || fm.includes(q); + }); + }, [items, search]); + + const selectedDirs = React.useMemo(() => Object.keys(selected).filter((k) => selected[k]), [selected]); + + const toggleAll = (value: boolean) => { + const next: Record = {}; + for (const item of items) { + if (!item.installable) continue; + next[item.skillDir] = value; + } + setSelected(next); + }; + + const handleScan = async () => { + const trimmed = source.trim(); + if (!trimmed) { + toast.error('Repository source is required'); + return; + } + + const result = await scanRepo({ + source: trimmed, + subpath: subpath.trim() || undefined, + gitIdentityId: gitIdentityId || undefined, + }); + + if (!result.ok) { + if (result.error?.kind === 'authRequired') { + if (isVSCodeRuntime()) { + toast.error('Private repositories are not supported in VS Code yet'); + return; + } + + const ids = (result.error.identities || []) as IdentityOption[]; + setIdentities(ids); + if (!gitIdentityId && ids.length > 0) { + setGitIdentityId(ids[0].id); + } + toast.error('Authentication required. Select a Git identity and try scanning again.'); + return; + } + + toast.error(result.error?.message || 'Failed to scan repository'); + return; + } + + const nextItems = result.items || []; + setItems(nextItems); + + // Auto-select all installable items when scanning returns a small set. + const nextSelected: Record = {}; + for (const item of nextItems) { + if (item.installable) { + nextSelected[item.skillDir] = true; + } + } + setSelected(nextSelected); + + setIdentities([]); + toast.success(`Found ${nextItems.length} skill(s)`); + }; + + const doInstall = async (opts: { conflictDecisions?: Record }) => { + if (selectedDirs.length === 0) { + toast.error('Select at least one skill to install'); + return; + } + + const request = { + source: source.trim(), + subpath: subpath.trim() || undefined, + scope, + selections: selectedDirs.map((dir) => ({ skillDir: dir })), + gitIdentityId: gitIdentityId || undefined, + }; + + const result = await installSkills({ + ...request, + conflictPolicy: 'prompt', + conflictDecisions: opts.conflictDecisions, + }); + + if (result.ok) { + const installedCount = result.installed?.length || 0; + toast.success(installedCount > 0 ? `Installed ${installedCount} skill(s)` : 'Installation completed'); + onOpenChange(false); + return; + } + + if (result.error?.kind === 'conflicts') { + setBaseInstallRequest(request); + setConflicts(result.error.conflicts); + setConflictsOpen(true); + return; + } + + if (result.error?.kind === 'authRequired') { + if (isVSCodeRuntime()) { + toast.error('Private repositories are not supported in VS Code yet'); + return; + } + const ids = (result.error.identities || []) as IdentityOption[]; + setIdentities(ids); + if (!gitIdentityId && ids.length > 0) { + setGitIdentityId(ids[0].id); + } + toast.error('Authentication required. Select a Git identity and try installing again.'); + return; + } + + toast.error(result.error?.message || 'Failed to install skills'); + }; + + return ( + <> + + + + Install from Git repository + + Scan a repository for folders containing SKILL.md, then install selected skills. + + + +
+
+ +
+ setSource(e.target.value)} + placeholder="owner/repo or git@github.com:owner/repo.git" + className="text-foreground placeholder:text-muted-foreground" + /> + +
+

+ For GitHub shorthand, you can add a subpath like owner/repo/skills. +

+
+ +
+
+ + setSubpath(e.target.value)} + placeholder="e.g. skills" + className="text-foreground placeholder:text-muted-foreground" + /> +
+ +
+ + +
+
+ + {identities.length > 0 && !isVSCodeRuntime() ? ( +
+
Authentication required
+
+ Select a Git identity (SSH key) that can access this repository. +
+
+ +
+
+ Configure identities in Settings → Git Identities. +
+
+ ) : null} +
+ +
+ {items.length === 0 ? ( +
+
+

No scan results yet

+

Scan a repository to discover skills

+
+
+ ) : ( +
+
+ setSearch(e.target.value)} + placeholder="Search skills…" + className="max-w-sm" + /> +
+ + +
+
+ + + {filteredItems.map((item) => { + const installed = installedByName.get(item.skillName); + const checked = Boolean(selected[item.skillDir]); + const disabled = !item.installable; + + return ( + + ); + })} + + +
+ Selected: {selectedDirs.length} / {items.filter((i) => i.installable).length} +
+
+ )} +
+ + + + void doInstall({})} + > + {isInstalling ? 'Installing…' : 'Install selected'} + + +
+
+ + { + if (!baseInstallRequest) { + setConflictsOpen(false); + return; + } + void doInstall({ conflictDecisions: decisions }); + setConflictsOpen(false); + }} + /> + + ); +}; diff --git a/packages/ui/src/components/sections/skills/catalog/InstallSkillDialog.tsx b/packages/ui/src/components/sections/skills/catalog/InstallSkillDialog.tsx new file mode 100644 index 00000000..1623d686 --- /dev/null +++ b/packages/ui/src/components/sections/skills/catalog/InstallSkillDialog.tsx @@ -0,0 +1,185 @@ +import React from 'react'; +import { toast } from 'sonner'; + +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, +} from '@/components/ui/select'; +import { RiFolderLine, RiUser3Line } from '@remixicon/react'; + +import type { SkillsCatalogItem } from '@/lib/api/types'; +import { useSkillsCatalogStore } from '@/stores/useSkillsCatalogStore'; +import { InstallConflictsDialog, type ConflictDecision, type SkillConflict } from './InstallConflictsDialog'; + +interface InstallSkillDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + item: SkillsCatalogItem | null; +} + +export const InstallSkillDialog: React.FC = ({ open, onOpenChange, item }) => { + const { installSkills, isInstalling } = useSkillsCatalogStore(); + const [scope, setScope] = React.useState<'user' | 'project'>('user'); + const [conflictsOpen, setConflictsOpen] = React.useState(false); + const [conflicts, setConflicts] = React.useState([]); + const [baseRequest, setBaseRequest] = React.useState<{ + source: string; + subpath?: string; + scope: 'user' | 'project'; + skillDir: string; + } | null>(null); + + React.useEffect(() => { + if (!open) return; + setScope('user'); + setConflictsOpen(false); + setConflicts([]); + setBaseRequest(null); + }, [open]); + + const doInstall = async (request: { + source: string; + subpath?: string; + scope: 'user' | 'project'; + skillDir: string; + conflictDecisions?: Record; + }) => { + const result = await installSkills({ + source: request.source, + subpath: request.subpath, + gitIdentityId: item?.gitIdentityId, + scope: request.scope, + selections: [{ skillDir: request.skillDir }], + conflictPolicy: 'prompt', + conflictDecisions: request.conflictDecisions, + }); + + if (result.ok) { + toast.success('Skill installed successfully'); + onOpenChange(false); + return; + } + + if (result.error?.kind === 'conflicts') { + setBaseRequest({ source: request.source, subpath: request.subpath, scope: request.scope, skillDir: request.skillDir }); + setConflicts(result.error.conflicts); + setConflictsOpen(true); + return; + } + + if (result.error?.kind === 'authRequired') { + toast.error(result.error.message || 'Authentication required'); + return; + } + + toast.error(result.error?.message || 'Failed to install skill'); + }; + + if (!item) { + return null; + } + + return ( + <> + + + + Install skill + + Install {item.skillName} into user or project scope. + + + +
+ {item.warnings?.length ? ( +
+
Warnings
+
    + {item.warnings.map((w) => ( +
  • {w}
  • + ))} +
+
+ ) : null} +
+ + +
+ +
+ +
+ + +
+
+
+
+ + { + if (!baseRequest) return; + void doInstall({ + source: baseRequest.source, + subpath: baseRequest.subpath, + scope: baseRequest.scope, + skillDir: baseRequest.skillDir, + conflictDecisions: decisions, + }); + setConflictsOpen(false); + }} + /> + + ); +}; diff --git a/packages/ui/src/components/sections/skills/catalog/SkillsCatalogPage.tsx b/packages/ui/src/components/sections/skills/catalog/SkillsCatalogPage.tsx new file mode 100644 index 00000000..4554552a --- /dev/null +++ b/packages/ui/src/components/sections/skills/catalog/SkillsCatalogPage.tsx @@ -0,0 +1,285 @@ +import React from 'react'; + +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; +import { AnimatedTabs } from '@/components/ui/animated-tabs'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, +} from '@/components/ui/select'; + +import { RiAddLine, RiDeleteBinLine, RiRefreshLine } from '@remixicon/react'; + +import { useSkillsCatalogStore } from '@/stores/useSkillsCatalogStore'; +import type { SkillsCatalogItem } from '@/lib/api/types'; + +import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry'; +import { getDesktopSettings, isDesktopRuntime } from '@/lib/desktop'; +import { updateDesktopSettings } from '@/lib/persistence'; +import type { DesktopSettings, SkillCatalogConfig } from '@/lib/desktop'; + +import { AddCatalogDialog } from './AddCatalogDialog'; +import { InstallSkillDialog } from './InstallSkillDialog'; + +type SkillsMode = 'manual' | 'external'; + +interface SkillsCatalogPageProps { + mode: SkillsMode; + onModeChange: (mode: SkillsMode) => void; +} + +const loadSettings = async (): Promise => { + try { + if (isDesktopRuntime()) { + return await getDesktopSettings(); + } + + const runtimeSettings = getRegisteredRuntimeAPIs()?.settings; + if (runtimeSettings) { + const result = await runtimeSettings.load(); + return (result?.settings || {}) as DesktopSettings; + } + + const response = await fetch('/api/config/settings', { + method: 'GET', + headers: { Accept: 'application/json' }, + }); + + if (!response.ok) { + return null; + } + + return (await response.json().catch(() => null)) as DesktopSettings | null; + } catch { + return null; + } +}; + +export const SkillsCatalogPage: React.FC = ({ mode, onModeChange }) => { + const { + sources, + itemsBySource, + selectedSourceId, + setSelectedSource, + loadCatalog, + isLoadingCatalog, + lastCatalogError, + } = useSkillsCatalogStore(); + + const [search, setSearch] = React.useState(''); + const [addCatalogOpen, setAddCatalogOpen] = React.useState(false); + const [installDialogOpen, setInstallDialogOpen] = React.useState(false); + const [installItem, setInstallItem] = React.useState(null); + const [isRemovingCatalog, setIsRemovingCatalog] = React.useState(false); + + React.useEffect(() => { + void loadCatalog(); + }, [loadCatalog]); + + const items = React.useMemo(() => { + if (!selectedSourceId) return []; + return itemsBySource[selectedSourceId] || []; + }, [itemsBySource, selectedSourceId]); + + const filtered = React.useMemo(() => { + const q = search.trim().toLowerCase(); + if (!q) return items; + return items.filter((item) => { + const name = item.skillName.toLowerCase(); + const desc = (item.description || '').toLowerCase(); + const fm = (item.frontmatterName || '').toLowerCase(); + return name.includes(q) || desc.includes(q) || fm.includes(q); + }); + }, [items, search]); + + const selectedSource = React.useMemo(() => sources.find((s) => s.id === selectedSourceId) || null, [sources, selectedSourceId]); + + const isCustomSource = Boolean(selectedSourceId && selectedSourceId.startsWith('custom:')); + + const removeSelectedCatalog = async () => { + if (!selectedSourceId || !isCustomSource) { + return; + } + + if (!window.confirm('Remove this catalog?')) { + return; + } + + setIsRemovingCatalog(true); + try { + const settings = await loadSettings(); + const catalogs = (Array.isArray(settings?.skillCatalogs) ? settings?.skillCatalogs : []) as SkillCatalogConfig[]; + const updated = catalogs.filter((c) => c.id !== selectedSourceId); + await updateDesktopSettings({ skillCatalogs: updated }); + await loadCatalog({ refresh: true }); + } finally { + setIsRemovingCatalog(false); + } + }; + + return ( + +
+ + +
+

Skills Catalog

+

+ Browse curated repositories and install skills into your OpenCode configuration. +

+
+
+ +
+
+
+ + +
+ +
+ + {isCustomSource ? ( + + ) : null} + +
+
+ +
+ setSearch(e.target.value)} + placeholder="Search skills…" + className="max-w-md" + /> +
+ {isLoadingCatalog ? 'Loading…' : `${filtered.length} skill(s)`} +
+
+ + {lastCatalogError ? ( +
+
Catalog error
+
{lastCatalogError.message}
+
+ ) : null} +
+ +
+ {filtered.length === 0 ? ( +
+

No skills found

+

Try a different search or refresh the catalog

+
+ ) : ( + filtered.map((item) => { + const installed = item.installed?.isInstalled; + const installedScope = item.installed?.scope; + + return ( +
+
+
+
+
{item.skillName}
+ {installed ? ( + + installed ({installedScope || 'unknown'}) + + ) : null} + {!item.installable ? ( + + not installable + + ) : null} +
+ {item.description ? ( +
{item.description}
+ ) : ( +
No description provided
+ )} + {item.warnings?.length ? ( +
{item.warnings.join(' · ')}
+ ) : null} +
+ + +
+
+ ); + }) + )} +
+ + + +
+ ); +}; diff --git a/packages/ui/src/components/ui/animated-tabs.tsx b/packages/ui/src/components/ui/animated-tabs.tsx index 587c43ca..89556d08 100644 --- a/packages/ui/src/components/ui/animated-tabs.tsx +++ b/packages/ui/src/components/ui/animated-tabs.tsx @@ -13,6 +13,7 @@ interface AnimatedTabsProps { onValueChange: (value: T) => void; className?: string; isInteractive?: boolean; + animate?: boolean; } export function AnimatedTabs({ @@ -21,6 +22,7 @@ export function AnimatedTabs({ onValueChange, className, isInteractive = true, + animate = true, }: AnimatedTabsProps) { const containerRef = React.useRef(null); const activeTabRef = React.useRef(null); @@ -41,7 +43,7 @@ export function AnimatedTabs({ container.style.clipPath = `inset(0 ${Number(100 - rightPercent).toFixed(2)}% 0 ${Number(leftPercent).toFixed(2)}% round 8px)`; }, []); - React.useEffect(() => { + React.useLayoutEffect(() => { updateClipPath(); }, [updateClipPath, value, tabs.length]); @@ -60,7 +62,10 @@ export function AnimatedTabs({
{tabs.map((tab) => { diff --git a/packages/ui/src/lib/api/types.ts b/packages/ui/src/lib/api/types.ts index d6249169..340a07f3 100644 --- a/packages/ui/src/lib/api/types.ts +++ b/packages/ui/src/lib/api/types.ts @@ -412,3 +412,87 @@ export interface RuntimeAPIs { } export type RuntimeAPISelector = (apis: RuntimeAPIs) => TValue; + +// ============== Skills Catalog Types ============== + +export type SkillsCatalogSourceId = string; + +export interface SkillsCatalogSource { + id: SkillsCatalogSourceId; + label: string; + description?: string; + source: string; + defaultSubpath?: string; +} + +export interface SkillsCatalogItemInstalledBadge { + isInstalled: boolean; + scope?: 'user' | 'project'; +} + +export interface SkillsCatalogItem { + sourceId: SkillsCatalogSourceId; + repoSource: string; + repoSubpath?: string; + gitIdentityId?: string; + skillDir: string; + skillName: string; + frontmatterName?: string; + description?: string; + installable: boolean; + warnings?: string[]; + installed?: SkillsCatalogItemInstalledBadge; +} + +export interface SkillsCatalogResponse { + ok: boolean; + sources?: SkillsCatalogSource[]; + itemsBySource?: Record; + error?: { kind: string; message: string }; +} + +export interface SkillsRepoScanRequest { + source: string; + subpath?: string; + gitIdentityId?: string; +} + +export type SkillsRepoScanError = + | { kind: 'authRequired'; message: string; sshOnly: true; identities?: Array<{ id: string; name: string }> } + | { kind: 'invalidSource'; message: string } + | { kind: 'gitUnavailable'; message: string } + | { kind: 'networkError'; message: string } + | { kind: 'unknown'; message: string }; + +export interface SkillsRepoScanResponse { + ok: boolean; + items?: SkillsCatalogItem[]; + error?: SkillsRepoScanError; +} + +export interface SkillsInstallSelection { + skillDir: string; +} + +export interface SkillsInstallRequest { + source: string; + subpath?: string; + gitIdentityId?: string; + scope: 'user' | 'project'; + selections: SkillsInstallSelection[]; + conflictPolicy?: 'prompt' | 'skipAll' | 'overwriteAll'; + conflictDecisions?: Record; +} + +export type SkillsInstallError = SkillsRepoScanError | { + kind: 'conflicts'; + message: string; + conflicts: Array<{ skillName: string; scope: 'user' | 'project' }>; +}; + +export interface SkillsInstallResponse { + ok: boolean; + installed?: Array<{ skillName: string; scope: 'user' | 'project' }>; + skipped?: Array<{ skillName: string; reason: string }>; + error?: SkillsInstallError; +} diff --git a/packages/ui/src/lib/desktop.ts b/packages/ui/src/lib/desktop.ts index ccadea7f..6340a2eb 100644 --- a/packages/ui/src/lib/desktop.ts +++ b/packages/ui/src/lib/desktop.ts @@ -27,6 +27,14 @@ export type DesktopServerInfo = { cliAvailable: boolean; }; +export type SkillCatalogConfig = { + id: string; + label: string; + source: string; + subpath?: string; + gitIdentityId?: string; +}; + export type DesktopSettings = { themeId?: string; useSystemTheme?: boolean; @@ -44,6 +52,9 @@ export type DesktopSettings = { defaultModel?: string; // format: "provider/model" defaultAgent?: string; queueModeEnabled?: boolean; + + // User-added skills catalogs (persisted to ~/.config/openchamber/settings.json) + skillCatalogs?: SkillCatalogConfig[]; }; export type DesktopSettingsApi = { diff --git a/packages/ui/src/lib/persistence.ts b/packages/ui/src/lib/persistence.ts index 35898e4a..f893c53e 100644 --- a/packages/ui/src/lib/persistence.ts +++ b/packages/ui/src/lib/persistence.ts @@ -44,6 +44,40 @@ type PersistApi = { onFinishHydration?: (callback: () => void) => (() => void) | void; }; +const sanitizeSkillCatalogs = (value: unknown): DesktopSettings['skillCatalogs'] | undefined => { + if (!Array.isArray(value)) { + return undefined; + } + + const result: NonNullable = []; + const seen = new Set(); + + for (const entry of value) { + if (!entry || typeof entry !== 'object') continue; + const candidate = entry as Record; + + const id = typeof candidate.id === 'string' ? candidate.id.trim() : ''; + const label = typeof candidate.label === 'string' ? candidate.label.trim() : ''; + const source = typeof candidate.source === 'string' ? candidate.source.trim() : ''; + const subpath = typeof candidate.subpath === 'string' ? candidate.subpath.trim() : ''; + const gitIdentityId = typeof candidate.gitIdentityId === 'string' ? candidate.gitIdentityId.trim() : ''; + + if (!id || !label || !source) continue; + if (seen.has(id)) continue; + seen.add(id); + + result.push({ + id, + label, + source, + ...(subpath ? { subpath } : {}), + ...(gitIdentityId ? { gitIdentityId } : {}), + }); + } + + return result; +}; + const getPersistApi = (): PersistApi | undefined => { const candidate = (useUIStore as unknown as { persist?: PersistApi }).persist; if (candidate && typeof candidate === 'object') { @@ -140,6 +174,11 @@ const sanitizeWebSettings = (payload: unknown): DesktopSettings | null => { result.queueModeEnabled = candidate.queueModeEnabled; } + const skillCatalogs = sanitizeSkillCatalogs(candidate.skillCatalogs); + if (skillCatalogs) { + result.skillCatalogs = skillCatalogs; + } + return result; }; diff --git a/packages/ui/src/stores/useSkillsCatalogStore.ts b/packages/ui/src/stores/useSkillsCatalogStore.ts new file mode 100644 index 00000000..2f5624e8 --- /dev/null +++ b/packages/ui/src/stores/useSkillsCatalogStore.ts @@ -0,0 +1,206 @@ +import { create } from 'zustand'; +import { devtools } from 'zustand/middleware'; + +import type { + SkillsCatalogResponse, + SkillsCatalogSource, + SkillsCatalogItem, + SkillsRepoScanRequest, + SkillsRepoScanResponse, + SkillsInstallRequest, + SkillsInstallResponse, + SkillsInstallError, +} from '@/lib/api/types'; + +import { useSkillsStore } from '@/stores/useSkillsStore'; + +const getCurrentDirectory = (): string | null => { + try { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const store = (window as any).__zustand_directory_store__; + if (store) { + return store.getState().currentDirectory; + } + } catch { + // ignore + } + return null; +}; + +export interface SkillsCatalogState { + sources: SkillsCatalogSource[]; + itemsBySource: Record; + selectedSourceId: string | null; + + isLoadingCatalog: boolean; + isScanning: boolean; + isInstalling: boolean; + + lastCatalogError: SkillsCatalogResponse['error'] | null; + lastScanError: SkillsRepoScanResponse['error'] | null; + lastInstallError: SkillsInstallError | null; + + scanResults: SkillsCatalogItem[] | null; + + setSelectedSource: (id: string | null) => void; + + loadCatalog: (options?: { refresh?: boolean }) => Promise; + scanRepo: (request: SkillsRepoScanRequest) => Promise; + installSkills: (request: SkillsInstallRequest) => Promise; +} + +export const useSkillsCatalogStore = create()( + devtools( + (set, get) => ({ + sources: [], + itemsBySource: {}, + selectedSourceId: null, + + isLoadingCatalog: false, + isScanning: false, + isInstalling: false, + + lastCatalogError: null, + lastScanError: null, + lastInstallError: null, + + scanResults: null, + + setSelectedSource: (id) => set({ selectedSourceId: id }), + + loadCatalog: async (options) => { + set({ isLoadingCatalog: true, lastCatalogError: null }); + + const previous = { + sources: get().sources, + itemsBySource: get().itemsBySource, + }; + + let lastError: SkillsCatalogResponse['error'] | null = null; + + try { + for (let attempt = 0; attempt < 3; attempt++) { + try { + const currentDirectory = getCurrentDirectory(); + const refresh = options?.refresh ? '&refresh=true' : ''; + const queryParams = currentDirectory + ? `?directory=${encodeURIComponent(currentDirectory)}${refresh}` + : refresh + ? `?refresh=true` + : ''; + + const response = await fetch(`/api/config/skills/catalog${queryParams}`, { + method: 'GET', + headers: { Accept: 'application/json' }, + }); + + const payload = (await response.json().catch(() => null)) as SkillsCatalogResponse | null; + if (!response.ok || !payload?.ok) { + lastError = payload?.error || { kind: 'unknown', message: `Failed to load catalog (${response.status})` }; + const waitMs = 200 * (attempt + 1); + await new Promise((resolve) => setTimeout(resolve, waitMs)); + continue; + } + + const sources = payload.sources || []; + const itemsBySource = payload.itemsBySource || {}; + const currentSelected = get().selectedSourceId; + const selectedSourceId = + (currentSelected && sources.some((s) => s.id === currentSelected)) + ? currentSelected + : (sources[0]?.id ?? null); + + set({ sources, itemsBySource, selectedSourceId }); + return true; + + } catch (error) { + lastError = { kind: 'unknown', message: error instanceof Error ? error.message : String(error) }; + const waitMs = 200 * (attempt + 1); + await new Promise((resolve) => setTimeout(resolve, waitMs)); + } + } + + set({ + sources: previous.sources, + itemsBySource: previous.itemsBySource, + lastCatalogError: lastError || { kind: 'unknown', message: 'Failed to load catalog' }, + }); + + return false; + } finally { + set({ isLoadingCatalog: false }); + } + }, + + scanRepo: async (request) => { + set({ isScanning: true, lastScanError: null, scanResults: null }); + try { + const currentDirectory = getCurrentDirectory(); + const queryParams = currentDirectory ? `?directory=${encodeURIComponent(currentDirectory)}` : ''; + + const response = await fetch(`/api/config/skills/scan${queryParams}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, + body: JSON.stringify(request), + }); + + const payload = (await response.json().catch(() => null)) as SkillsRepoScanResponse | null; + if (!response.ok || !payload) { + const error = payload?.error || { kind: 'unknown', message: 'Failed to scan repository' }; + set({ lastScanError: error }); + return { ok: false, error }; + } + + if (!payload.ok) { + set({ lastScanError: payload.error || { kind: 'unknown', message: 'Failed to scan repository' } }); + return payload; + } + + set({ scanResults: payload.items || [] }); + return payload; + } finally { + set({ isScanning: false }); + } + }, + + installSkills: async (request) => { + set({ isInstalling: true, lastInstallError: null }); + try { + const currentDirectory = getCurrentDirectory(); + const queryParams = currentDirectory ? `?directory=${encodeURIComponent(currentDirectory)}` : ''; + + const response = await fetch(`/api/config/skills/install${queryParams}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, + body: JSON.stringify(request), + }); + + const payload = (await response.json().catch(() => null)) as SkillsInstallResponse | null; + if (!payload) { + const error = { kind: 'unknown', message: 'Failed to install skills' } as SkillsInstallError; + set({ lastInstallError: error }); + return { ok: false, error }; + } + + if (!response.ok || !payload.ok) { + const error = payload.error || ({ kind: 'unknown', message: 'Failed to install skills' } as SkillsInstallError); + set({ lastInstallError: error }); + return { ok: false, error }; + } + + // Refresh installed skills list. + void useSkillsStore.getState().loadSkills(); + + return payload; + } catch (error) { + const err = { kind: 'unknown', message: error instanceof Error ? error.message : String(error) } as SkillsInstallError; + set({ lastInstallError: err }); + return { ok: false, error: err }; + } finally { + set({ isInstalling: false }); + } + }, + }), + { name: 'skills-catalog-store' } + ) +); diff --git a/packages/vscode/package.json b/packages/vscode/package.json index df5de432..4adbd92e 100644 --- a/packages/vscode/package.json +++ b/packages/vscode/package.json @@ -136,6 +136,7 @@ "@openchamber/ui": "workspace:*", "@opencode-ai/sdk": "^1.0.209", "react": "^19.1.1", - "react-dom": "^19.1.1" + "react-dom": "^19.1.1", + "yaml": "^2.8.1" } } diff --git a/packages/vscode/src/bridge.ts b/packages/vscode/src/bridge.ts index 11c0cf0c..94848e30 100644 --- a/packages/vscode/src/bridge.ts +++ b/packages/vscode/src/bridge.ts @@ -4,6 +4,12 @@ import * as path from 'path'; import type { OpenCodeManager } from './opencode'; import { createAgent, createCommand, deleteAgent, deleteCommand, getAgentSources, getCommandSources, updateAgent, updateCommand, type AgentScope, type CommandScope, AGENT_SCOPE, COMMAND_SCOPE, discoverSkills, getSkillSources, createSkill, updateSkill, deleteSkill, readSkillSupportingFile, writeSkillSupportingFile, deleteSkillSupportingFile, type SkillScope, SKILL_SCOPE } from './opencodeConfig'; import { removeProviderAuth } from './opencodeAuth'; +import { + getSkillsCatalog, + scanSkillsRepository as scanSkillsRepositoryFromGit, + installSkillsFromRepository as installSkillsFromGit, + type SkillsCatalogSourceConfig, +} from './skillsCatalog'; export interface BridgeRequest { id: string; @@ -836,6 +842,73 @@ export async function handleBridgeMessage(message: BridgeRequest, ctx?: BridgeCo return { id, type, success: false, error: `Unsupported method: ${normalizedMethod}` }; } + case 'api:config/skills:catalog': { + const refresh = Boolean((payload as { refresh?: boolean } | undefined)?.refresh); + const workingDirectory = ctx?.manager?.getWorkingDirectory() || vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; + + const settings = readSettings(ctx); + const rawCatalogs = (settings as { skillCatalogs?: unknown }).skillCatalogs; + + const additionalSources: SkillsCatalogSourceConfig[] = Array.isArray(rawCatalogs) + ? (rawCatalogs + .map((entry) => { + if (!entry || typeof entry !== 'object') return null; + const candidate = entry as Record; + const id = typeof candidate.id === 'string' ? candidate.id.trim() : ''; + const label = typeof candidate.label === 'string' ? candidate.label.trim() : ''; + const source = typeof candidate.source === 'string' ? candidate.source.trim() : ''; + const subpath = typeof candidate.subpath === 'string' ? candidate.subpath.trim() : ''; + if (!id || !label || !source) return null; + const normalized: SkillsCatalogSourceConfig = { + id, + label, + description: source, + source, + ...(subpath ? { defaultSubpath: subpath } : {}), + }; + return normalized; + }) + .filter((v) => v !== null) as SkillsCatalogSourceConfig[]) + : []; + + const data = await getSkillsCatalog(workingDirectory, refresh, additionalSources); + return { id, type, success: true, data }; + } + + case 'api:config/skills:scan': { + const body = (payload || {}) as { source?: string; subpath?: string; gitIdentityId?: string }; + const data = await scanSkillsRepositoryFromGit({ + source: String(body.source || ''), + subpath: body.subpath, + }); + return { id, type, success: true, data }; + } + + case 'api:config/skills:install': { + const body = (payload || {}) as { + source?: string; + subpath?: string; + scope?: 'user' | 'project'; + selections?: Array<{ skillDir: string }>; + conflictPolicy?: 'prompt' | 'skipAll' | 'overwriteAll'; + conflictDecisions?: Record; + }; + + const workingDirectory = ctx?.manager?.getWorkingDirectory() || vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; + + const data = await installSkillsFromGit({ + source: String(body.source || ''), + subpath: body.subpath, + scope: body.scope === 'project' ? 'project' : 'user', + workingDirectory: body.scope === 'project' ? workingDirectory : undefined, + selections: Array.isArray(body.selections) ? body.selections : [], + conflictPolicy: body.conflictPolicy, + conflictDecisions: body.conflictDecisions, + }); + + return { id, type, success: true, data }; + } + case 'api:config/skills/files': { const { method, name, filePath, content } = (payload || {}) as { method?: string; diff --git a/packages/vscode/src/skillsCatalog.ts b/packages/vscode/src/skillsCatalog.ts new file mode 100644 index 00000000..77b8cc0d --- /dev/null +++ b/packages/vscode/src/skillsCatalog.ts @@ -0,0 +1,586 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { execFile } from 'child_process'; +import { promisify } from 'util'; +import yaml from 'yaml'; + +import { discoverSkills } from './opencodeConfig'; + +const execFileAsync = promisify(execFile); + +const DEFAULT_TIMEOUT_MS = 60_000; +const DEFAULT_MAX_BUFFER = 4 * 1024 * 1024; + +const SKILL_NAME_PATTERN = /^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$/; + +type SkillScope = 'user' | 'project'; + +export type SkillsCatalogSourceConfig = { + id: string; + label: string; + description?: string; + source: string; + defaultSubpath?: string; +}; + +type CuratedSource = SkillsCatalogSourceConfig; + +type SkillFrontmatter = { + name?: unknown; + description?: unknown; + [key: string]: unknown; +}; + +export type SkillsCatalogItem = { + repoSource: string; + repoSubpath?: string; + skillDir: string; + skillName: string; + frontmatterName?: string; + description?: string; + installable: boolean; + warnings?: string[]; +}; + +type SkillsCatalogItemWithBadge = SkillsCatalogItem & { + sourceId: string; + installed: { isInstalled: boolean; scope?: SkillScope }; +}; + +type SkillsRepoError = + | { kind: 'authRequired'; message: string; sshOnly: boolean } + | { kind: 'invalidSource'; message: string } + | { kind: 'gitUnavailable'; message: string } + | { kind: 'networkError'; message: string } + | { kind: 'unknown'; message: string } + | { kind: 'conflicts'; message: string; conflicts: Array<{ skillName: string; scope: SkillScope }> }; + +type SkillsRepoScanResult = + | { ok: true; items: SkillsCatalogItem[] } + | { ok: false; error: SkillsRepoError }; + +type SkillsInstallResult = + | { ok: true; installed: Array<{ skillName: string; scope: SkillScope }>; skipped: Array<{ skillName: string; reason: string }> } + | { ok: false; error: SkillsRepoError }; + +export const CURATED_SOURCES: CuratedSource[] = [ + { + id: 'anthropic', + label: 'Anthropic', + description: "Anthropic’s public skills repository", + source: 'anthropics/skills', + defaultSubpath: 'skills', + }, +]; + +function validateSkillName(skillName: string): boolean { + if (skillName.length < 1 || skillName.length > 64) return false; + return SKILL_NAME_PATTERN.test(skillName); +} + +function looksLikeAuthError(message: string): boolean { + return ( + /permission denied/i.test(message) || + /publickey/i.test(message) || + /could not read from remote repository/i.test(message) || + /authentication failed/i.test(message) + ); +} + +async function runGit(args: string[], options?: { cwd?: string; timeoutMs?: number }) { + try { + const { stdout, stderr } = await execFileAsync('git', args, { + cwd: options?.cwd, + timeout: options?.timeoutMs ?? DEFAULT_TIMEOUT_MS, + maxBuffer: DEFAULT_MAX_BUFFER, + env: { + ...process.env, + GIT_TERMINAL_PROMPT: '0', + }, + }); + return { ok: true as const, stdout: stdout || '', stderr: stderr || '' }; + } catch (error) { + const err = error as { stdout?: string; stderr?: string; message?: string }; + return { + ok: false as const, + stdout: typeof err.stdout === 'string' ? err.stdout : '', + stderr: typeof err.stderr === 'string' ? err.stderr : '', + message: typeof err.message === 'string' ? err.message : 'Git command failed', + }; + } +} + +async function assertGitAvailable() { + const result = await runGit(['--version'], { timeoutMs: 5_000 }); + if (!result.ok) { + return { ok: false as const, error: { kind: 'gitUnavailable' as const, message: 'Git is not available in PATH' } }; + } + return { ok: true as const }; +} + +function parseSkillRepoSource(input: string, subpath?: string) { + const raw = (input || '').trim(); + if (!raw) { + return { ok: false as const, error: { kind: 'invalidSource' as const, message: 'Repository source is required' } }; + } + + const explicitSubpath = subpath?.trim() ? subpath.trim() : null; + + const sshMatch = raw.match(/^git@github\.com:([^/\s]+)\/([^\s#]+)$/i); + if (sshMatch) { + const owner = sshMatch[1]; + const repo = sshMatch[2].replace(/\.git$/i, ''); + return { + ok: true as const, + normalizedRepo: `${owner}/${repo}`, + cloneUrlHttps: `https://github.com/${owner}/${repo}.git`, + cloneUrlSsh: `git@github.com:${owner}/${repo}.git`, + effectiveSubpath: explicitSubpath, + }; + } + + const httpsMatch = raw.match(/^https?:\/\/github\.com\/([^/\s]+)\/([^\s#]+)$/i); + if (httpsMatch) { + const owner = httpsMatch[1]; + const repo = httpsMatch[2].replace(/\.git$/i, ''); + return { + ok: true as const, + normalizedRepo: `${owner}/${repo}`, + cloneUrlHttps: `https://github.com/${owner}/${repo}.git`, + cloneUrlSsh: `git@github.com:${owner}/${repo}.git`, + effectiveSubpath: explicitSubpath, + }; + } + + const shorthandMatch = raw.match(/^([^/\s]+)\/([^/\s]+)(?:\/(.+))?$/); + if (shorthandMatch) { + const owner = shorthandMatch[1]; + const repo = shorthandMatch[2].replace(/\.git$/i, ''); + const shorthandSubpath = shorthandMatch[3]?.trim() || null; + return { + ok: true as const, + normalizedRepo: `${owner}/${repo}`, + cloneUrlHttps: `https://github.com/${owner}/${repo}.git`, + cloneUrlSsh: `git@github.com:${owner}/${repo}.git`, + effectiveSubpath: explicitSubpath || shorthandSubpath, + }; + } + + return { ok: false as const, error: { kind: 'invalidSource' as const, message: 'Unsupported repository source format' } }; +} + +function parseSkillMd(content: string): { frontmatter: SkillFrontmatter; warnings: string[] } { + const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/); + if (!match) { + return { + frontmatter: {}, + warnings: ['Invalid SKILL.md: missing YAML frontmatter delimiter'], + }; + } + + try { + const parsed = yaml.parse(match[1]); + const frontmatter = parsed && typeof parsed === 'object' ? (parsed as SkillFrontmatter) : {}; + return { frontmatter, warnings: [] }; + } catch { + return { + frontmatter: {}, + warnings: ['Invalid SKILL.md: failed to parse YAML frontmatter'], + }; + } +} + +async function safeRm(dir: string) { + try { + await fs.promises.rm(dir, { recursive: true, force: true }); + } catch { + // ignore + } +} + +async function cloneRepo(cloneUrl: string, targetDir: string) { + const preferred = ['clone', '--depth', '1', '--filter=blob:none', '--no-checkout', cloneUrl, targetDir]; + const fallback = ['clone', '--depth', '1', '--no-checkout', cloneUrl, targetDir]; + + const result = await runGit(preferred, { timeoutMs: 60_000 }); + if (result.ok) return { ok: true as const }; + + const fallbackResult = await runGit(fallback, { timeoutMs: 60_000 }); + if (fallbackResult.ok) return { ok: true as const }; + + const combined = `${fallbackResult.stderr}\n${fallbackResult.message}`.trim(); + if (looksLikeAuthError(combined)) { + return { + ok: false as const, + error: { + kind: 'authRequired' as const, + message: 'Private repositories are not supported in VS Code yet. Use Desktop/Web.', + sshOnly: true, + }, + }; + } + + return { ok: false as const, error: { kind: 'networkError' as const, message: combined || 'Failed to clone repository' } }; +} + +export async function scanSkillsRepository(options: { source: string; subpath?: string; defaultSubpath?: string }): Promise { + const gitCheck = await assertGitAvailable(); + if (!gitCheck.ok) { + return { ok: false as const, error: gitCheck.error }; + } + + const parsed = parseSkillRepoSource(options.source, options.subpath); + if (!parsed.ok) { + return { ok: false as const, error: parsed.error }; + } + + const effectiveSubpath = parsed.effectiveSubpath || options.defaultSubpath || null; + const tempBase = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'openchamber-vscode-skills-scan-')); + + try { + const cloned = await cloneRepo(parsed.cloneUrlHttps, tempBase); + if (!cloned.ok) { + return { ok: false as const, error: cloned.error }; + } + + const toFsPath = (posixPath: string) => path.join(tempBase, ...posixPath.split('/').filter(Boolean)); + + const patterns = effectiveSubpath + ? [`${effectiveSubpath}/SKILL.md`, `${effectiveSubpath}/**/SKILL.md`] + : ['SKILL.md', '**/SKILL.md']; + + let skillMdPaths: string[] | null = null; + + const sparseInit = await runGit(['-C', tempBase, 'sparse-checkout', 'init', '--no-cone'], { timeoutMs: 15_000 }); + if (sparseInit.ok) { + const sparseSet = await runGit(['-C', tempBase, 'sparse-checkout', 'set', ...patterns], { timeoutMs: 30_000 }); + if (sparseSet.ok) { + const checkout = await runGit(['-C', tempBase, 'checkout', '--force', 'HEAD'], { timeoutMs: 60_000 }); + if (checkout.ok) { + const lsFiles = await runGit(['-C', tempBase, 'ls-files'], { timeoutMs: 15_000 }); + if (lsFiles.ok) { + skillMdPaths = lsFiles.stdout + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean) + .filter((p) => p.endsWith('/SKILL.md') || p === 'SKILL.md'); + } + } + } + } + + if (!Array.isArray(skillMdPaths)) { + const listArgs = ['-C', tempBase, 'ls-tree', '-r', '--name-only', 'HEAD']; + if (effectiveSubpath) { + listArgs.push('--', effectiveSubpath); + } + + const list = await runGit(listArgs, { timeoutMs: 30_000 }); + if (!list.ok) { + return { ok: true as const, items: [] as SkillsCatalogItem[] }; + } + + skillMdPaths = list.stdout + .split(/\r?\n/) + .map((l) => l.trim()) + .filter(Boolean) + .filter((p) => p.endsWith('/SKILL.md') || p === 'SKILL.md'); + } + + const skillDirs = Array.from(new Set(skillMdPaths.filter((p) => p !== 'SKILL.md').map((p) => path.posix.dirname(p)))); + + const items: SkillsCatalogItem[] = []; + + for (const skillDir of skillDirs) { + const skillName = path.posix.basename(skillDir); + const skillMdPath = path.posix.join(skillDir, 'SKILL.md'); + + const warnings: string[] = []; + let content = ''; + + try { + content = await fs.promises.readFile(toFsPath(skillMdPath), 'utf8'); + } catch { + const show = await runGit(['-C', tempBase, 'show', `HEAD:${skillMdPath}`], { timeoutMs: 15_000 }); + if (!show.ok) { + warnings.push('Failed to read SKILL.md'); + } else { + content = show.stdout; + } + } + + const parsedMd = parseSkillMd(content); + warnings.push(...parsedMd.warnings); + + const description = typeof parsedMd.frontmatter.description === 'string' ? parsedMd.frontmatter.description : undefined; + const frontmatterName = typeof parsedMd.frontmatter.name === 'string' ? parsedMd.frontmatter.name : undefined; + + const installable = validateSkillName(skillName); + if (!installable) { + warnings.push('Skill directory name is not a valid OpenCode skill name'); + } + + items.push({ + repoSource: options.source, + repoSubpath: effectiveSubpath || undefined, + skillDir, + skillName, + frontmatterName, + description, + installable, + warnings: warnings.length ? warnings : undefined, + }); + } + + items.sort((a, b) => String(a.skillName).localeCompare(String(b.skillName))); + + return { ok: true as const, items }; + } finally { + await safeRm(tempBase); + } +} + +async function copyDirectoryNoSymlinks(srcDir: string, dstDir: string) { + const srcReal = await fs.promises.realpath(srcDir); + + const ensureDir = async (dirPath: string) => { + await fs.promises.mkdir(dirPath, { recursive: true }); + }; + + const walk = async (currentSrc: string, currentDst: string) => { + const entries = await fs.promises.readdir(currentSrc, { withFileTypes: true }); + for (const entry of entries) { + const nextSrc = path.join(currentSrc, entry.name); + const nextDst = path.join(currentDst, entry.name); + + const stat = await fs.promises.lstat(nextSrc); + if (stat.isSymbolicLink()) { + throw new Error('Symlinks are not supported in skills'); + } + + const nextRealParent = await fs.promises.realpath(path.dirname(nextSrc)); + if (!nextRealParent.startsWith(srcReal)) { + throw new Error('Invalid source path traversal detected'); + } + + if (stat.isDirectory()) { + await ensureDir(nextDst); + await walk(nextSrc, nextDst); + continue; + } + + if (stat.isFile()) { + await ensureDir(path.dirname(nextDst)); + await fs.promises.copyFile(nextSrc, nextDst); + try { + await fs.promises.chmod(nextDst, stat.mode & 0o777); + } catch { + // best-effort + } + } + } + }; + + await ensureDir(dstDir); + await walk(srcDir, dstDir); +} + +function getUserSkillBaseDir() { + return path.join(os.homedir(), '.config', 'opencode', 'skill'); +} + +function toFsPath(repoDir: string, repoRelPosixPath: string) { + const parts = repoRelPosixPath.split('/').filter(Boolean); + return path.join(repoDir, ...parts); +} + +export async function installSkillsFromRepository(options: { + source: string; + subpath?: string; + scope: SkillScope; + workingDirectory?: string; + selections: Array<{ skillDir: string }>; + conflictPolicy?: 'prompt' | 'skipAll' | 'overwriteAll'; + conflictDecisions?: Record; +}): Promise { + const gitCheck = await assertGitAvailable(); + if (!gitCheck.ok) { + return { ok: false as const, error: gitCheck.error }; + } + + if (options.scope === 'project' && !options.workingDirectory) { + return { ok: false as const, error: { kind: 'invalidSource' as const, message: 'Project installs require a directory parameter' } }; + } + + const parsed = parseSkillRepoSource(options.source, options.subpath); + if (!parsed.ok) { + return { ok: false as const, error: parsed.error }; + } + + const requestedDirs = options.selections.map((s) => String(s.skillDir || '').trim()).filter(Boolean); + if (requestedDirs.length === 0) { + return { ok: false as const, error: { kind: 'invalidSource' as const, message: 'No skills selected for installation' } }; + } + + const userSkillDir = getUserSkillBaseDir(); + + const skillPlans = requestedDirs.map((dir) => { + const skillName = path.posix.basename(dir); + return { skillDirPosix: dir, skillName, installable: validateSkillName(skillName) }; + }); + + const conflicts: Array<{ skillName: string; scope: SkillScope }> = []; + for (const plan of skillPlans) { + if (!plan.installable) continue; + const targetDir = options.scope === 'user' + ? path.join(userSkillDir, plan.skillName) + : path.join(options.workingDirectory as string, '.opencode', 'skill', plan.skillName); + + if (fs.existsSync(targetDir)) { + const decision = options.conflictDecisions?.[plan.skillName]; + const hasAutoPolicy = options.conflictPolicy === 'skipAll' || options.conflictPolicy === 'overwriteAll'; + if (!decision && !hasAutoPolicy) { + conflicts.push({ skillName: plan.skillName, scope: options.scope }); + } + } + } + + if (conflicts.length > 0) { + return { + ok: false as const, + error: { kind: 'conflicts' as const, message: 'Some skills already exist in the selected scope', conflicts }, + }; + } + + const tempBase = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'openchamber-vscode-skills-install-')); + + try { + const cloned = await cloneRepo(parsed.cloneUrlHttps, tempBase); + if (!cloned.ok) { + return { ok: false as const, error: cloned.error }; + } + + await runGit(['-C', tempBase, 'sparse-checkout', 'init', '--cone'], { timeoutMs: 15_000 }); + const setResult = await runGit(['-C', tempBase, 'sparse-checkout', 'set', ...requestedDirs], { timeoutMs: 30_000 }); + if (!setResult.ok) { + return { ok: false as const, error: { kind: 'unknown' as const, message: setResult.stderr || setResult.message || 'Failed to configure sparse checkout' } }; + } + + const checkoutResult = await runGit(['-C', tempBase, 'checkout', '--force', 'HEAD'], { timeoutMs: 60_000 }); + if (!checkoutResult.ok) { + return { ok: false as const, error: { kind: 'unknown' as const, message: checkoutResult.stderr || checkoutResult.message || 'Failed to checkout repository' } }; + } + + const installed: Array<{ skillName: string; scope: SkillScope }> = []; + const skipped: Array<{ skillName: string; reason: string }> = []; + + for (const plan of skillPlans) { + if (!plan.installable) { + skipped.push({ skillName: plan.skillName, reason: 'Invalid skill name (directory basename)' }); + continue; + } + + const srcDir = toFsPath(tempBase, plan.skillDirPosix); + const skillMdPath = path.join(srcDir, 'SKILL.md'); + if (!fs.existsSync(skillMdPath)) { + skipped.push({ skillName: plan.skillName, reason: 'SKILL.md not found in selected directory' }); + continue; + } + + const targetDir = options.scope === 'user' + ? path.join(userSkillDir, plan.skillName) + : path.join(options.workingDirectory as string, '.opencode', 'skill', plan.skillName); + + const exists = fs.existsSync(targetDir); + let decision = options.conflictDecisions?.[plan.skillName] || null; + if (!decision) { + if (exists && options.conflictPolicy === 'skipAll') decision = 'skip'; + if (exists && options.conflictPolicy === 'overwriteAll') decision = 'overwrite'; + if (!exists) decision = 'overwrite'; + } + + if (exists && decision === 'skip') { + skipped.push({ skillName: plan.skillName, reason: 'Already installed (skipped)' }); + continue; + } + + if (exists && decision === 'overwrite') { + await safeRm(targetDir); + } + + await fs.promises.mkdir(path.dirname(targetDir), { recursive: true }); + + try { + await copyDirectoryNoSymlinks(srcDir, targetDir); + installed.push({ skillName: plan.skillName, scope: options.scope }); + } catch (error) { + await safeRm(targetDir); + skipped.push({ + skillName: plan.skillName, + reason: error instanceof Error ? error.message : 'Failed to copy skill files', + }); + } + } + + return { ok: true as const, installed, skipped }; + } finally { + await safeRm(tempBase); + } +} + +const catalogCache = new Map(); +const CATALOG_TTL_MS = 30 * 60 * 1000; + +export async function getSkillsCatalog( + workingDirectory?: string, + refresh?: boolean, + additionalSources?: SkillsCatalogSourceConfig[] +) { + const sources = [...CURATED_SOURCES, ...(Array.isArray(additionalSources) ? additionalSources : [])]; + const discovered = discoverSkills(workingDirectory); + const installedByName = new Map(discovered.map((s) => [s.name, s])); + + const itemsBySource: Record = {}; + + for (const src of sources) { + const parsed = parseSkillRepoSource(src.source); + if (!parsed.ok) { + itemsBySource[src.id] = []; + continue; + } + + const effectiveSubpath = src.defaultSubpath || parsed.effectiveSubpath || ''; + const cacheKey = `${parsed.normalizedRepo}::${effectiveSubpath}`; + + let cached = !refresh ? catalogCache.get(cacheKey) : null; + if (cached && Date.now() >= cached.expiresAt) { + catalogCache.delete(cacheKey); + cached = null; + } + + let items: SkillsCatalogItem[] = []; + if (cached) { + items = cached.items; + } else { + const scanned = await scanSkillsRepository({ source: src.source, defaultSubpath: src.defaultSubpath }); + if (!scanned.ok) { + itemsBySource[src.id] = []; + continue; + } + items = scanned.items || []; + catalogCache.set(cacheKey, { expiresAt: Date.now() + CATALOG_TTL_MS, items }); + } + + itemsBySource[src.id] = items.map((item) => { + const installed = installedByName.get(item.skillName); + return { + sourceId: src.id, + ...item, + installed: installed ? { isInstalled: true, scope: installed.scope } : { isInstalled: false }, + }; + }); + } + + return { ok: true as const, sources, itemsBySource }; +} diff --git a/packages/vscode/webview/main.tsx b/packages/vscode/webview/main.tsx index d6e8266d..20b3b33e 100644 --- a/packages/vscode/webview/main.tsx +++ b/packages/vscode/webview/main.tsx @@ -408,6 +408,54 @@ const handleLocalApiRequest = async (url: URL, init?: RequestInit) => { } } + const skillsCatalogStatusFromPayload = (payload: unknown): number => { + if (!payload || typeof payload !== 'object') return 200; + const data = payload as { ok?: boolean; error?: { kind?: string } }; + if (data.ok === false) { + const kind = data.error?.kind; + if (kind === 'conflicts') return 409; + if (kind === 'authRequired') return 401; + return 400; + } + return 200; + }; + + // Skills catalog: /api/config/skills/catalog + if (pathname === '/api/config/skills/catalog') { + const refresh = url.searchParams.get('refresh') === 'true'; + try { + const data = await sendBridgeMessage('api:config/skills:catalog', { refresh }); + return new Response(JSON.stringify(data), { status: skillsCatalogStatusFromPayload(data), headers: { 'Content-Type': 'application/json' } }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return new Response(JSON.stringify({ ok: false, error: { kind: 'unknown', message } }), { status: 500, headers: { 'Content-Type': 'application/json' } }); + } + } + + // Skills scan: /api/config/skills/scan + if (pathname === '/api/config/skills/scan') { + const body = init?.body ? JSON.parse(init.body as string) : {}; + try { + const data = await sendBridgeMessage('api:config/skills:scan', body); + return new Response(JSON.stringify(data), { status: skillsCatalogStatusFromPayload(data), headers: { 'Content-Type': 'application/json' } }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return new Response(JSON.stringify({ ok: false, error: { kind: 'unknown', message } }), { status: 500, headers: { 'Content-Type': 'application/json' } }); + } + } + + // Skills install: /api/config/skills/install + if (pathname === '/api/config/skills/install') { + const body = init?.body ? JSON.parse(init.body as string) : {}; + try { + const data = await sendBridgeMessage('api:config/skills:install', body); + return new Response(JSON.stringify(data), { status: skillsCatalogStatusFromPayload(data), headers: { 'Content-Type': 'application/json' } }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return new Response(JSON.stringify({ ok: false, error: { kind: 'unknown', message } }), { status: 500, headers: { 'Content-Type': 'application/json' } }); + } + } + // Skills CRUD: /api/config/skills/:name or /api/config/skills if (pathname === '/api/config/skills') { try { diff --git a/packages/web/server/index.js b/packages/web/server/index.js index 8a3b6b03..70cf3ebf 100644 --- a/packages/web/server/index.js +++ b/packages/web/server/index.js @@ -350,6 +350,39 @@ const normalizeStringArray = (input) => { ); }; +const sanitizeSkillCatalogs = (input) => { + if (!Array.isArray(input)) { + return undefined; + } + + const result = []; + const seen = new Set(); + + for (const entry of input) { + if (!entry || typeof entry !== 'object') continue; + + const id = typeof entry.id === 'string' ? entry.id.trim() : ''; + const label = typeof entry.label === 'string' ? entry.label.trim() : ''; + const source = typeof entry.source === 'string' ? entry.source.trim() : ''; + const subpath = typeof entry.subpath === 'string' ? entry.subpath.trim() : ''; + const gitIdentityId = typeof entry.gitIdentityId === 'string' ? entry.gitIdentityId.trim() : ''; + + if (!id || !label || !source) continue; + if (seen.has(id)) continue; + seen.add(id); + + result.push({ + id, + label, + source, + ...(subpath ? { subpath } : {}), + ...(gitIdentityId ? { gitIdentityId } : {}), + }); + } + + return result; +}; + const sanitizeSettingsUpdate = (payload) => { if (!payload || typeof payload !== 'object') { return {}; @@ -425,6 +458,11 @@ const sanitizeSettingsUpdate = (payload) => { result.queueModeEnabled = candidate.queueModeEnabled; } + const skillCatalogs = sanitizeSkillCatalogs(candidate.skillCatalogs); + if (skillCatalogs) { + result.skillCatalogs = skillCatalogs; + } + return result; }; @@ -2376,7 +2414,8 @@ async function main(options = {}) { readSkillSupportingFile, writeSkillSupportingFile, deleteSkillSupportingFile, - SKILL_SCOPE + SKILL_SCOPE, + SKILL_DIR, } = await import('./lib/opencode-config.js'); // List all discovered skills @@ -2401,6 +2440,210 @@ async function main(options = {}) { } }); + // ============== SKILLS CATALOG + INSTALL ENDPOINTS ============== + + const { getCuratedSkillsSources } = await import('./lib/skills-catalog/curated-sources.js'); + const { getCacheKey, getCachedScan, setCachedScan } = await import('./lib/skills-catalog/cache.js'); + const { parseSkillRepoSource } = await import('./lib/skills-catalog/source.js'); + const { scanSkillsRepository } = await import('./lib/skills-catalog/scan.js'); + const { installSkillsFromRepository } = await import('./lib/skills-catalog/install.js'); + const { getProfiles, getProfile } = await import('./lib/git-identity-storage.js'); + + const listGitIdentitiesForResponse = () => { + try { + const profiles = getProfiles(); + return profiles.map((p) => ({ id: p.id, name: p.name })); + } catch { + return []; + } + }; + + const resolveGitIdentity = (profileId) => { + if (!profileId) { + return null; + } + try { + const profile = getProfile(profileId); + const sshKey = profile?.sshKey; + if (typeof sshKey === 'string' && sshKey.trim()) { + return { sshKey: sshKey.trim() }; + } + } catch { + // ignore + } + return null; + }; + + app.get('/api/config/skills/catalog', async (req, res) => { + try { + const workingDirectory = req.query.directory || openCodeWorkingDirectory; + const refresh = String(req.query.refresh || '').toLowerCase() === 'true'; + + const curatedSources = getCuratedSkillsSources(); + const settings = await readSettingsFromDisk(); + const customSourcesRaw = sanitizeSkillCatalogs(settings.skillCatalogs) || []; + + const customSources = customSourcesRaw.map((entry) => ({ + id: entry.id, + label: entry.label, + description: entry.source, + source: entry.source, + defaultSubpath: entry.subpath, + gitIdentityId: entry.gitIdentityId, + })); + + const sources = [...curatedSources, ...customSources]; + + const discovered = discoverSkills(workingDirectory); + const installedByName = new Map(discovered.map((s) => [s.name, s])); + + const itemsBySource = {}; + + for (const src of sources) { + const parsed = parseSkillRepoSource(src.source); + if (!parsed.ok) { + itemsBySource[src.id] = []; + continue; + } + + const effectiveSubpath = src.defaultSubpath || parsed.effectiveSubpath || null; + const cacheKey = getCacheKey({ + normalizedRepo: parsed.normalizedRepo, + subpath: effectiveSubpath || '', + identityId: src.gitIdentityId || '', + }); + + let scanResult = !refresh ? getCachedScan(cacheKey) : null; + if (!scanResult) { + const scanned = await scanSkillsRepository({ + source: src.source, + subpath: src.defaultSubpath, + defaultSubpath: src.defaultSubpath, + identity: resolveGitIdentity(src.gitIdentityId), + }); + + if (!scanned.ok) { + itemsBySource[src.id] = []; + continue; + } + + scanResult = scanned; + setCachedScan(cacheKey, scanResult); + } + + const items = (scanResult.items || []).map((item) => { + const installed = installedByName.get(item.skillName); + return { + sourceId: src.id, + ...item, + gitIdentityId: src.gitIdentityId, + installed: installed + ? { isInstalled: true, scope: installed.scope } + : { isInstalled: false }, + }; + }); + + itemsBySource[src.id] = items; + } + + const sourcesForUi = sources.map(({ gitIdentityId, ...rest }) => rest); + res.json({ ok: true, sources: sourcesForUi, itemsBySource }); + } catch (error) { + console.error('Failed to load skills catalog:', error); + res.status(500).json({ ok: false, error: { kind: 'unknown', message: error.message || 'Failed to load catalog' } }); + } + }); + + app.post('/api/config/skills/scan', async (req, res) => { + try { + const { source, subpath, gitIdentityId } = req.body || {}; + const identity = resolveGitIdentity(gitIdentityId); + + const result = await scanSkillsRepository({ + source, + subpath, + identity, + }); + + if (!result.ok) { + if (result.error?.kind === 'authRequired') { + return res.status(401).json({ + ok: false, + error: { + ...result.error, + identities: listGitIdentitiesForResponse(), + }, + }); + } + + return res.status(400).json({ ok: false, error: result.error }); + } + + res.json({ ok: true, items: result.items }); + } catch (error) { + console.error('Failed to scan skills repository:', error); + res.status(500).json({ ok: false, error: { kind: 'unknown', message: error.message || 'Failed to scan repository' } }); + } + }); + + app.post('/api/config/skills/install', async (req, res) => { + try { + const { + source, + subpath, + gitIdentityId, + scope, + selections, + conflictPolicy, + conflictDecisions, + } = req.body || {}; + + const workingDirectory = req.query.directory; + if (scope === 'project' && !workingDirectory) { + return res.status(400).json({ + ok: false, + error: { kind: 'invalidSource', message: 'Project installs require a directory parameter' }, + }); + } + const identity = resolveGitIdentity(gitIdentityId); + + const result = await installSkillsFromRepository({ + source, + subpath, + identity, + scope, + workingDirectory, + userSkillDir: SKILL_DIR, + selections, + conflictPolicy, + conflictDecisions, + }); + + if (!result.ok) { + if (result.error?.kind === 'conflicts') { + return res.status(409).json({ ok: false, error: result.error }); + } + + if (result.error?.kind === 'authRequired') { + return res.status(401).json({ + ok: false, + error: { + ...result.error, + identities: listGitIdentitiesForResponse(), + }, + }); + } + + return res.status(400).json({ ok: false, error: result.error }); + } + + res.json({ ok: true, installed: result.installed || [], skipped: result.skipped || [] }); + } catch (error) { + console.error('Failed to install skills:', error); + res.status(500).json({ ok: false, error: { kind: 'unknown', message: error.message || 'Failed to install skills' } }); + } + }); + // Get single skill sources app.get('/api/config/skills/:name', (req, res) => { try { diff --git a/packages/web/server/lib/skills-catalog/cache.js b/packages/web/server/lib/skills-catalog/cache.js new file mode 100644 index 00000000..10800acb --- /dev/null +++ b/packages/web/server/lib/skills-catalog/cache.js @@ -0,0 +1,29 @@ +const DEFAULT_TTL_MS = 30 * 60 * 1000; + +const cache = new Map(); + +export function getCacheKey({ normalizedRepo, subpath, identityId }) { + const safeRepo = String(normalizedRepo || '').trim(); + const safeSubpath = String(subpath || '').trim(); + const safeIdentity = String(identityId || '').trim(); + return `${safeRepo}::${safeSubpath}::${safeIdentity}`; +} + +export function getCachedScan(key) { + const entry = cache.get(key); + if (!entry) return null; + if (Date.now() >= entry.expiresAt) { + cache.delete(key); + return null; + } + return entry.value; +} + +export function setCachedScan(key, value, ttlMs = DEFAULT_TTL_MS) { + const ttl = Number.isFinite(ttlMs) ? ttlMs : DEFAULT_TTL_MS; + cache.set(key, { expiresAt: Date.now() + ttl, value }); +} + +export function clearCache() { + cache.clear(); +} diff --git a/packages/web/server/lib/skills-catalog/curated-sources.js b/packages/web/server/lib/skills-catalog/curated-sources.js new file mode 100644 index 00000000..ec4d9187 --- /dev/null +++ b/packages/web/server/lib/skills-catalog/curated-sources.js @@ -0,0 +1,13 @@ +export const CURATED_SKILLS_SOURCES = [ + { + id: 'anthropic', + label: 'Anthropic', + description: "Anthropic’s public skills repository", + source: 'anthropics/skills', + defaultSubpath: 'skills', + }, +]; + +export function getCuratedSkillsSources() { + return CURATED_SKILLS_SOURCES.slice(); +} diff --git a/packages/web/server/lib/skills-catalog/git.js b/packages/web/server/lib/skills-catalog/git.js new file mode 100644 index 00000000..10bb076b --- /dev/null +++ b/packages/web/server/lib/skills-catalog/git.js @@ -0,0 +1,76 @@ +import { execFile } from 'child_process'; +import { promisify } from 'util'; + +const execFileAsync = promisify(execFile); + +const DEFAULT_TIMEOUT_MS = 60_000; +const DEFAULT_MAX_BUFFER = 4 * 1024 * 1024; + +export function looksLikeAuthError(message) { + const text = String(message || ''); + return ( + /permission denied/i.test(text) || + /publickey/i.test(text) || + /could not read from remote repository/i.test(text) || + /authentication failed/i.test(text) || + /fatal: could not/i.test(text) + ); +} + +export async function runGit(args, options = {}) { + const cwd = options.cwd; + const timeoutMs = Number.isFinite(options.timeoutMs) ? options.timeoutMs : DEFAULT_TIMEOUT_MS; + const maxBuffer = Number.isFinite(options.maxBuffer) ? options.maxBuffer : DEFAULT_MAX_BUFFER; + + const identity = options.identity || null; + const normalizedArgs = Array.isArray(args) ? args.slice() : []; + + // Non-interactive git (avoid prompts / hangs) + const env = { + ...process.env, + GIT_TERMINAL_PROMPT: '0', + }; + + if (identity?.sshKey) { + const sshKeyPath = String(identity.sshKey).trim(); + if (sshKeyPath) { + // Avoid interactive host key prompts; still safe against changed keys. + const sshCommand = `ssh -i ${sshKeyPath} -o BatchMode=yes -o StrictHostKeyChecking=accept-new`; + normalizedArgs.unshift(`core.sshCommand=${sshCommand}`); + normalizedArgs.unshift('-c'); + } + } + + try { + const { stdout, stderr } = await execFileAsync('git', normalizedArgs, { + cwd, + env, + timeout: timeoutMs, + maxBuffer, + }); + + return { ok: true, stdout: stdout || '', stderr: stderr || '' }; + } catch (error) { + const err = error; + const stdout = typeof err?.stdout === 'string' ? err.stdout : ''; + const stderr = typeof err?.stderr === 'string' ? err.stderr : ''; + const message = err instanceof Error ? err.message : String(err); + + return { + ok: false, + stdout, + stderr, + message, + code: typeof err?.code === 'number' ? err.code : null, + signal: typeof err?.signal === 'string' ? err.signal : null, + }; + } +} + +export async function assertGitAvailable() { + const result = await runGit(['--version'], { timeoutMs: 5_000 }); + if (!result.ok) { + return { ok: false, error: { kind: 'gitUnavailable', message: 'Git is not available in PATH' } }; + } + return { ok: true }; +} diff --git a/packages/web/server/lib/skills-catalog/install.js b/packages/web/server/lib/skills-catalog/install.js new file mode 100644 index 00000000..8fee7f0b --- /dev/null +++ b/packages/web/server/lib/skills-catalog/install.js @@ -0,0 +1,264 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +import { assertGitAvailable, looksLikeAuthError, runGit } from './git.js'; +import { parseSkillRepoSource } from './source.js'; + +const SKILL_NAME_PATTERN = /^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$/; + +function validateSkillName(skillName) { + if (typeof skillName !== 'string') return false; + if (skillName.length < 1 || skillName.length > 64) return false; + return SKILL_NAME_PATTERN.test(skillName); +} + +async function safeRm(dir) { + try { + await fs.promises.rm(dir, { recursive: true, force: true }); + } catch { + // ignore + } +} + +function toFsPath(repoDir, repoRelPosixPath) { + const parts = String(repoRelPosixPath || '') + .split('/') + .map((p) => p.trim()) + .filter(Boolean); + return path.join(repoDir, ...parts); +} + +async function ensureDir(dirPath) { + await fs.promises.mkdir(dirPath, { recursive: true }); +} + +async function copyDirectoryNoSymlinks(srcDir, dstDir) { + const srcReal = await fs.promises.realpath(srcDir); + await ensureDir(dstDir); + + const walk = async (currentSrc, currentDst) => { + const entries = await fs.promises.readdir(currentSrc, { withFileTypes: true }); + for (const entry of entries) { + const nextSrc = path.join(currentSrc, entry.name); + const nextDst = path.join(currentDst, entry.name); + + const stat = await fs.promises.lstat(nextSrc); + if (stat.isSymbolicLink()) { + throw new Error('Symlinks are not supported in skills'); + } + + // Guard against traversal: ensure source is still under srcReal + const nextRealParent = await fs.promises.realpath(path.dirname(nextSrc)); + if (!nextRealParent.startsWith(srcReal)) { + throw new Error('Invalid source path traversal detected'); + } + + if (stat.isDirectory()) { + await ensureDir(nextDst); + await walk(nextSrc, nextDst); + continue; + } + + if (stat.isFile()) { + await ensureDir(path.dirname(nextDst)); + await fs.promises.copyFile(nextSrc, nextDst); + try { + await fs.promises.chmod(nextDst, stat.mode & 0o777); + } catch { + // best-effort + } + continue; + } + + // Skip other types (sockets, devices, etc.) + } + }; + + await walk(srcDir, dstDir); +} + +async function cloneRepo({ cloneUrl, identity, tempDir }) { + const preferred = ['clone', '--depth', '1', '--filter=blob:none', '--no-checkout', cloneUrl, tempDir]; + const fallback = ['clone', '--depth', '1', '--no-checkout', cloneUrl, tempDir]; + + const result = await runGit(preferred, { identity, timeoutMs: 90_000 }); + if (result.ok) return { ok: true }; + + const fallbackResult = await runGit(fallback, { identity, timeoutMs: 90_000 }); + if (fallbackResult.ok) return { ok: true }; + + return { + ok: false, + error: fallbackResult, + }; +} + +function getTargetSkillDir({ scope, workingDirectory, userSkillDir, skillName }) { + if (scope === 'user') { + return path.join(userSkillDir, skillName); + } + + if (!workingDirectory) { + throw new Error('workingDirectory is required for project installs'); + } + + return path.join(workingDirectory, '.opencode', 'skill', skillName); +} + +export async function installSkillsFromRepository({ + source, + subpath, + defaultSubpath, + identity, + scope, + workingDirectory, + userSkillDir, + selections, + conflictPolicy, + conflictDecisions, +} = {}) { + const gitCheck = await assertGitAvailable(); + if (!gitCheck.ok) { + return { ok: false, error: gitCheck.error }; + } + + if (scope !== 'user' && scope !== 'project') { + return { ok: false, error: { kind: 'invalidSource', message: 'Invalid scope' } }; + } + + if (!userSkillDir) { + return { ok: false, error: { kind: 'unknown', message: 'userSkillDir is required' } }; + } + + if (scope === 'project' && !workingDirectory) { + return { ok: false, error: { kind: 'invalidSource', message: 'Project installs require a directory parameter' } }; + } + + const parsed = parseSkillRepoSource(source, { subpath }); + if (!parsed.ok) { + return { ok: false, error: parsed.error }; + } + + const effectiveSubpath = parsed.effectiveSubpath || (typeof defaultSubpath === 'string' && defaultSubpath.trim() ? defaultSubpath.trim() : null); + void effectiveSubpath; + + const cloneUrl = identity?.sshKey ? parsed.cloneUrlSsh : parsed.cloneUrlHttps; + + const requestedDirs = Array.isArray(selections) ? selections.map((s) => String(s?.skillDir || '').trim()).filter(Boolean) : []; + if (requestedDirs.length === 0) { + return { ok: false, error: { kind: 'invalidSource', message: 'No skills selected for installation' } }; + } + + // Validate names early and compute conflicts without mutating. + const skillPlans = requestedDirs.map((skillDirPosix) => { + const skillName = path.posix.basename(skillDirPosix); + return { skillDirPosix, skillName, installable: validateSkillName(skillName) }; + }); + + const conflicts = []; + for (const plan of skillPlans) { + if (!plan.installable) { + continue; + } + + const targetDir = getTargetSkillDir({ scope, workingDirectory, userSkillDir, skillName: plan.skillName }); + if (fs.existsSync(targetDir)) { + const decision = conflictDecisions?.[plan.skillName]; + const hasAutoPolicy = conflictPolicy === 'skipAll' || conflictPolicy === 'overwriteAll'; + if (!decision && !hasAutoPolicy) { + conflicts.push({ skillName: plan.skillName, scope }); + } + } + } + + if (conflicts.length > 0) { + return { + ok: false, + error: { + kind: 'conflicts', + message: 'Some skills already exist in the selected scope', + conflicts, + }, + }; + } + + const tempBase = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'openchamber-skills-install-')); + + try { + const cloned = await cloneRepo({ cloneUrl, identity, tempDir: tempBase }); + if (!cloned.ok) { + const msg = `${cloned.error?.stderr || ''}\n${cloned.error?.message || ''}`.trim(); + if (looksLikeAuthError(msg)) { + return { ok: false, error: { kind: 'authRequired', message: 'Authentication required to access this repository', sshOnly: true } }; + } + return { ok: false, error: { kind: 'networkError', message: msg || 'Failed to clone repository' } }; + } + + // Selective checkout for only requested skill dirs. + await runGit(['-C', tempBase, 'sparse-checkout', 'init', '--cone'], { identity, timeoutMs: 15_000 }); + const setResult = await runGit(['-C', tempBase, 'sparse-checkout', 'set', ...requestedDirs], { identity, timeoutMs: 30_000 }); + if (!setResult.ok) { + return { ok: false, error: { kind: 'unknown', message: setResult.stderr || setResult.message || 'Failed to configure sparse checkout' } }; + } + + const checkoutResult = await runGit(['-C', tempBase, 'checkout', '--force', 'HEAD'], { identity, timeoutMs: 60_000 }); + if (!checkoutResult.ok) { + return { ok: false, error: { kind: 'unknown', message: checkoutResult.stderr || checkoutResult.message || 'Failed to checkout repository' } }; + } + + const installed = []; + const skipped = []; + + for (const plan of skillPlans) { + if (!plan.installable) { + skipped.push({ skillName: plan.skillName, reason: 'Invalid skill name (directory basename)' }); + continue; + } + + const srcDir = toFsPath(tempBase, plan.skillDirPosix); + const skillMdPath = path.join(srcDir, 'SKILL.md'); + if (!fs.existsSync(skillMdPath)) { + skipped.push({ skillName: plan.skillName, reason: 'SKILL.md not found in selected directory' }); + continue; + } + + const targetDir = getTargetSkillDir({ scope, workingDirectory, userSkillDir, skillName: plan.skillName }); + const exists = fs.existsSync(targetDir); + + let decision = conflictDecisions?.[plan.skillName] || null; + if (!decision) { + if (exists && conflictPolicy === 'skipAll') decision = 'skip'; + if (exists && conflictPolicy === 'overwriteAll') decision = 'overwrite'; + if (!exists) decision = 'overwrite'; // no conflict, proceed + } + + if (exists && decision === 'skip') { + skipped.push({ skillName: plan.skillName, reason: 'Already installed (skipped)' }); + continue; + } + + if (exists && decision === 'overwrite') { + await safeRm(targetDir); + } + + // Ensure project parent directories exist + await ensureDir(path.dirname(targetDir)); + + try { + await copyDirectoryNoSymlinks(srcDir, targetDir); + installed.push({ skillName: plan.skillName, scope }); + } catch (error) { + await safeRm(targetDir); + skipped.push({ + skillName: plan.skillName, + reason: error instanceof Error ? error.message : 'Failed to copy skill files', + }); + } + } + + return { ok: true, installed, skipped }; + } finally { + await safeRm(tempBase); + } +} diff --git a/packages/web/server/lib/skills-catalog/scan.js b/packages/web/server/lib/skills-catalog/scan.js new file mode 100644 index 00000000..c95123de --- /dev/null +++ b/packages/web/server/lib/skills-catalog/scan.js @@ -0,0 +1,221 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import yaml from 'yaml'; + +import { assertGitAvailable, looksLikeAuthError, runGit } from './git.js'; +import { parseSkillRepoSource } from './source.js'; + +const SKILL_NAME_PATTERN = /^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$/; + +function validateSkillName(skillName) { + if (typeof skillName !== 'string') return false; + if (skillName.length < 1 || skillName.length > 64) return false; + return SKILL_NAME_PATTERN.test(skillName); +} + +function parseSkillMd(content) { + const text = typeof content === 'string' ? content : ''; + const match = text.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/); + if (!match) { + return { + ok: true, + frontmatter: {}, + warnings: ['Invalid SKILL.md: missing YAML frontmatter delimiter'], + }; + } + + try { + const frontmatter = yaml.parse(match[1]) || {}; + return { ok: true, frontmatter, warnings: [] }; + } catch { + return { + ok: true, + frontmatter: {}, + warnings: ['Invalid SKILL.md: failed to parse YAML frontmatter'], + }; + } +} + +async function safeRm(dir) { + try { + await fs.promises.rm(dir, { recursive: true, force: true }); + } catch { + // ignore + } +} + +async function cloneRepo({ cloneUrl, identity, tempDir }) { + const preferred = ['clone', '--depth', '1', '--filter=blob:none', '--no-checkout', cloneUrl, tempDir]; + const fallback = ['clone', '--depth', '1', '--no-checkout', cloneUrl, tempDir]; + + const result = await runGit(preferred, { identity, timeoutMs: 60_000 }); + if (result.ok) return { ok: true }; + + const fallbackResult = await runGit(fallback, { identity, timeoutMs: 60_000 }); + if (fallbackResult.ok) return { ok: true }; + + return { + ok: false, + error: fallbackResult, + }; +} + +export async function scanSkillsRepository({ + source, + subpath, + defaultSubpath, + identity, +} = {}) { + const gitCheck = await assertGitAvailable(); + if (!gitCheck.ok) { + return { ok: false, error: gitCheck.error }; + } + + const parsed = parseSkillRepoSource(source, { subpath }); + if (!parsed.ok) { + return { ok: false, error: parsed.error }; + } + + const effectiveSubpath = parsed.effectiveSubpath || (typeof defaultSubpath === 'string' && defaultSubpath.trim() ? defaultSubpath.trim() : null); + const cloneUrl = identity?.sshKey ? parsed.cloneUrlSsh : parsed.cloneUrlHttps; + + const tempBase = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'openchamber-skills-scan-')); + + try { + const cloned = await cloneRepo({ cloneUrl, identity, tempDir: tempBase }); + if (!cloned.ok) { + const msg = `${cloned.error?.stderr || ''}\n${cloned.error?.message || ''}`.trim(); + if (looksLikeAuthError(msg)) { + return { ok: false, error: { kind: 'authRequired', message: 'Authentication required to access this repository', sshOnly: true } }; + } + return { ok: false, error: { kind: 'networkError', message: msg || 'Failed to clone repository' } }; + } + + const toFsPath = (posixPath) => path.join(tempBase, ...String(posixPath || '').split('/').filter(Boolean)); + + const patterns = effectiveSubpath + ? [`${effectiveSubpath}/SKILL.md`, `${effectiveSubpath}/**/SKILL.md`] + : ['SKILL.md', '**/SKILL.md']; + + let skillMdPaths = null; + + // Fast path: sparse checkout only SKILL.md files, then parse from disk. + // This avoids one `git show` per skill. + const sparseInit = await runGit(['-C', tempBase, 'sparse-checkout', 'init', '--no-cone'], { identity, timeoutMs: 15_000 }); + if (sparseInit.ok) { + const sparseSet = await runGit(['-C', tempBase, 'sparse-checkout', 'set', ...patterns], { identity, timeoutMs: 30_000 }); + if (sparseSet.ok) { + const checkout = await runGit(['-C', tempBase, 'checkout', '--force', 'HEAD'], { identity, timeoutMs: 60_000 }); + if (checkout.ok) { + const lsFiles = await runGit(['-C', tempBase, 'ls-files'], { identity, timeoutMs: 15_000 }); + if (lsFiles.ok) { + skillMdPaths = lsFiles.stdout + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean) + .filter((p) => p.endsWith('/SKILL.md') || p === 'SKILL.md'); + } + } + } + } + + // Fallback: list tree and read SKILL.md blobs via git. + if (!Array.isArray(skillMdPaths)) { + const listArgs = ['-C', tempBase, 'ls-tree', '-r', '--name-only', 'HEAD']; + if (effectiveSubpath) { + listArgs.push('--', effectiveSubpath); + } + + const listResult = await runGit(listArgs, { identity, timeoutMs: 30_000 }); + if (!listResult.ok) { + // If subpath doesn't exist, treat as empty scan. + return { + ok: true, + normalizedRepo: parsed.normalizedRepo, + effectiveSubpath, + items: [], + }; + } + + skillMdPaths = listResult.stdout + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean) + .filter((p) => p.endsWith('/SKILL.md') || p === 'SKILL.md'); + } + + // Root-level SKILL.md doesn't map cleanly to OpenCode's "skill name == folder name" convention. + const uniqueSkillDirs = Array.from( + new Set( + skillMdPaths + .filter((p) => p !== 'SKILL.md') + .map((p) => path.posix.dirname(p)) + ) + ); + + const items = []; + const maxParallel = 10; + let idx = 0; + + const worker = async () => { + while (idx < uniqueSkillDirs.length) { + const skillDir = uniqueSkillDirs[idx++]; + const skillName = path.posix.basename(skillDir); + const skillMdPath = path.posix.join(skillDir, 'SKILL.md'); + + const warnings = []; + let skillMdContent = ''; + + // Prefer filesystem reads when sparse checkout succeeded. + const filePath = toFsPath(skillMdPath); + try { + skillMdContent = await fs.promises.readFile(filePath, 'utf8'); + } catch { + const showResult = await runGit(['-C', tempBase, 'show', `HEAD:${skillMdPath}`], { identity, timeoutMs: 15_000 }); + if (!showResult.ok) { + warnings.push('Failed to read SKILL.md'); + } else { + skillMdContent = showResult.stdout; + } + } + + const parsedMd = parseSkillMd(skillMdContent); + warnings.push(...(parsedMd.warnings || [])); + + const description = typeof parsedMd.frontmatter?.description === 'string' ? parsedMd.frontmatter.description : undefined; + const frontmatterName = typeof parsedMd.frontmatter?.name === 'string' ? parsedMd.frontmatter.name : undefined; + + const installable = validateSkillName(skillName); + if (!installable) { + warnings.push('Skill directory name is not a valid OpenCode skill name'); + } + + items.push({ + repoSource: source, + repoSubpath: effectiveSubpath || undefined, + skillDir, + skillName, + frontmatterName, + description, + installable, + warnings: warnings.length ? warnings : undefined, + }); + } + }; + + await Promise.all(Array.from({ length: Math.min(maxParallel, uniqueSkillDirs.length || 1) }, () => worker())); + + // Stable ordering for UX + items.sort((a, b) => a.skillName.localeCompare(b.skillName)); + + return { + ok: true, + normalizedRepo: parsed.normalizedRepo, + effectiveSubpath, + items, + }; + } finally { + await safeRm(tempBase); + } +} diff --git a/packages/web/server/lib/skills-catalog/source.js b/packages/web/server/lib/skills-catalog/source.js new file mode 100644 index 00000000..701e96ab --- /dev/null +++ b/packages/web/server/lib/skills-catalog/source.js @@ -0,0 +1,85 @@ +const GITHUB_HOST = 'github.com'; + +function normalizeGitHubOwnerRepo(owner, repo) { + const normalizedOwner = String(owner || '').trim(); + const normalizedRepo = String(repo || '').trim().replace(/\.git$/i, ''); + if (!normalizedOwner || !normalizedRepo) { + return null; + } + return { owner: normalizedOwner, repo: normalizedRepo }; +} + +export function parseSkillRepoSource(input, options = {}) { + const raw = typeof input === 'string' ? input.trim() : ''; + if (!raw) { + return { ok: false, error: { kind: 'invalidSource', message: 'Repository source is required' } }; + } + + const explicitSubpath = typeof options.subpath === 'string' && options.subpath.trim() ? options.subpath.trim() : null; + + // SSH URL: git@github.com:owner/repo(.git) + const sshMatch = raw.match(/^git@github\.com:([^/\s]+)\/([^\s#]+)$/i); + if (sshMatch) { + const parsed = normalizeGitHubOwnerRepo(sshMatch[1], sshMatch[2]); + if (!parsed) { + return { ok: false, error: { kind: 'invalidSource', message: 'Invalid SSH repository URL' } }; + } + + return { + ok: true, + host: GITHUB_HOST, + owner: parsed.owner, + repo: parsed.repo, + cloneUrlSsh: `git@github.com:${parsed.owner}/${parsed.repo}.git`, + cloneUrlHttps: `https://github.com/${parsed.owner}/${parsed.repo}.git`, + // For SSH URLs, subpath is only accepted via options.subpath + effectiveSubpath: explicitSubpath, + normalizedRepo: `${parsed.owner}/${parsed.repo}`, + }; + } + + // HTTPS URL: https://github.com/owner/repo(.git) + const httpsMatch = raw.match(/^https?:\/\/github\.com\/([^/\s]+)\/([^\s#]+)$/i); + if (httpsMatch) { + const parsed = normalizeGitHubOwnerRepo(httpsMatch[1], httpsMatch[2]); + if (!parsed) { + return { ok: false, error: { kind: 'invalidSource', message: 'Invalid HTTPS repository URL' } }; + } + + return { + ok: true, + host: GITHUB_HOST, + owner: parsed.owner, + repo: parsed.repo, + cloneUrlSsh: `git@github.com:${parsed.owner}/${parsed.repo}.git`, + cloneUrlHttps: `https://github.com/${parsed.owner}/${parsed.repo}.git`, + effectiveSubpath: explicitSubpath, + normalizedRepo: `${parsed.owner}/${parsed.repo}`, + }; + } + + // Shorthand: owner/repo[/subpath...] + const shorthandMatch = raw.match(/^([^/\s]+)\/([^/\s]+)(?:\/(.+))?$/); + if (shorthandMatch) { + const parsed = normalizeGitHubOwnerRepo(shorthandMatch[1], shorthandMatch[2]); + if (!parsed) { + return { ok: false, error: { kind: 'invalidSource', message: 'Invalid repository source' } }; + } + + const shorthandSubpath = typeof shorthandMatch[3] === 'string' && shorthandMatch[3].trim() ? shorthandMatch[3].trim() : null; + const effectiveSubpath = explicitSubpath || shorthandSubpath; + + return { + ok: true, + host: GITHUB_HOST, + owner: parsed.owner, + repo: parsed.repo, + cloneUrlSsh: `git@github.com:${parsed.owner}/${parsed.repo}.git`, + cloneUrlHttps: `https://github.com/${parsed.owner}/${parsed.repo}.git`, + effectiveSubpath, + normalizedRepo: `${parsed.owner}/${parsed.repo}`, + }; + } + + return { ok: false, error: { kind: 'invalidSource', message: 'Unsupported repository source format' } }; +}