From 2f0a980b1ffbc286e18155a23e965aa10ac7ea96 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Thu, 26 Mar 2026 23:33:10 -0700 Subject: [PATCH 1/2] refactor(skills): migrate to runtime registry install model --- .gitignore | 1 + .gitmodules | 3 - package.json | 6 +- skills | 1 - src-tauri/Cargo.lock | 2 +- src-tauri/src/commands/runtime.rs | 73 ++++- src-tauri/src/lib.rs | 27 ++ src-tauri/src/runtime/mod.rs | 2 + src-tauri/src/runtime/qjs_engine.rs | 61 +--- src-tauri/src/runtime/registry.rs | 475 ++++++++++++++++++++++++++++ src-tauri/tauri.conf.json | 1 - src/components/SkillsGrid.tsx | 63 ++-- src/lib/skills/paths.ts | 13 +- src/pages/Skills.tsx | 158 ++++++++- src/providers/SkillProvider.tsx | 6 + src/utils/tauriCommands.ts | 55 ++++ 16 files changed, 833 insertions(+), 114 deletions(-) delete mode 100644 .gitmodules delete mode 160000 skills create mode 100644 src-tauri/src/runtime/registry.rs diff --git a/.gitignore b/.gitignore index 428fc2ab61..468afe1393 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,7 @@ yarn.lock node_modules dist dist-ssr +/skills/ *.local # Environment variables diff --git a/.gitmodules b/.gitmodules deleted file mode 100644 index 5ea2ee6e63..0000000000 --- a/.gitmodules +++ /dev/null @@ -1,3 +0,0 @@ -[submodule "skills"] - path = skills - url = https://github.com/tinyhumansai/openhuman-skills.git diff --git a/package.json b/package.json index a776675e7f..aaa9719b26 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "dev:web": "vite", "dev:app": "source scripts/load-dotenv.sh && tauri dev", "build": "tsc && vite build", - "build:app": "yarn skills:build && tsc && vite build", + "build:app": "tsc && vite build", "compile": "tsc --noEmit", "preview": "vite preview", "tauri": "tauri", @@ -37,8 +37,8 @@ "format:check": "prettier --check .", "lint": "eslint . --ext .ts,.tsx", "lint:fix": "eslint . --ext .ts,.tsx --fix", - "skills:build": "cd skills && yarn build", - "skills:watch": "cd skills && yarn build:watch", + "skills:build": "echo \"skills are runtime-installed from registry\"", + "skills:watch": "echo \"skills are runtime-installed from registry\"", "prepare": "husky" }, "dependencies": { diff --git a/skills b/skills deleted file mode 160000 index 0685a6af4e..0000000000 --- a/skills +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 0685a6af4ef3ddea4fb26364dd72ea574f307314 diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index bb512679f8..9b02b47dec 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -4,7 +4,7 @@ version = 4 [[package]] name = "OpenHuman" -version = "0.49.12" +version = "0.49.15" dependencies = [ "aes-gcm", "android_logger", diff --git a/src-tauri/src/commands/runtime.rs b/src-tauri/src/commands/runtime.rs index b3d4cdf248..caa6f77436 100644 --- a/src-tauri/src/commands/runtime.rs +++ b/src-tauri/src/commands/runtime.rs @@ -10,7 +10,6 @@ use crate::models::socket::SocketState; use crate::runtime::socket_manager::SocketManager; use crate::utils::config::get_backend_url; use std::sync::Arc; -use std::collections::HashMap; use tauri::State; use serde::{Deserialize, Serialize}; @@ -75,6 +74,10 @@ pub struct ZeroClawToolResult { #[cfg(not(any(target_os = "android", target_os = "ios")))] mod desktop { use super::*; + use crate::runtime::registry::{ + install_or_update_skill, list_catalog, sync_core_skills, uninstall_skill, + RegistryCatalogEntry, RegistrySyncResult, + }; /// List all skills discovered from the skills directory (including not-yet-started). #[tauri::command] @@ -111,6 +114,44 @@ mod desktop { Ok(result) } + #[tauri::command] + pub async fn registry_sync_core( + engine: State<'_, Arc>, + ) -> Result { + sync_core_skills(&engine) + } + + #[tauri::command] + pub async fn registry_list_catalog( + engine: State<'_, Arc>, + ) -> Result, String> { + list_catalog(&engine) + } + + #[tauri::command] + pub async fn registry_install_skill( + engine: State<'_, Arc>, + skill_id: String, + ) -> Result<(), String> { + install_or_update_skill(&engine, &skill_id) + } + + #[tauri::command] + pub async fn registry_update_skill( + engine: State<'_, Arc>, + skill_id: String, + ) -> Result<(), String> { + install_or_update_skill(&engine, &skill_id) + } + + #[tauri::command] + pub async fn registry_uninstall_skill( + engine: State<'_, Arc>, + skill_id: String, + ) -> Result<(), String> { + uninstall_skill(&engine, &skill_id) + } + /// List all currently registered (running/stopped/error) skill instances. #[tauri::command] pub async fn runtime_list_skills( @@ -498,6 +539,36 @@ mod mobile { Ok(vec![]) } + #[tauri::command] + pub async fn registry_sync_core() -> Result { + Ok(serde_json::json!({ + "repo_path": "", + "updated_core": [], + "skipped_core": [], + "errors": [] + })) + } + + #[tauri::command] + pub async fn registry_list_catalog() -> Result, String> { + Ok(vec![]) + } + + #[tauri::command] + pub async fn registry_install_skill(_skill_id: String) -> Result<(), String> { + Err(MOBILE_ERROR.to_string()) + } + + #[tauri::command] + pub async fn registry_update_skill(_skill_id: String) -> Result<(), String> { + Err(MOBILE_ERROR.to_string()) + } + + #[tauri::command] + pub async fn registry_uninstall_skill(_skill_id: String) -> Result<(), String> { + Err(MOBILE_ERROR.to_string()) + } + #[tauri::command] pub async fn runtime_list_skills() -> Result, String> { Ok(vec![]) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index e48fa5e2b4..da469512e5 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -766,6 +766,7 @@ pub fn run() { match runtime::qjs_engine::RuntimeEngine::new(skills_data_dir) { Ok(engine) => { engine.set_app_handle(app.handle().clone()); + engine.set_skills_source_dir(data_dir.join("skills").join("installed")); // Set resource directory for bundled skills (production builds) if let Ok(resource_dir) = app.path().resource_dir() { @@ -797,6 +798,22 @@ pub fn run() { // lightweight contexts don't have V8's memory reservation issue) let engine_clone = engine.clone(); tauri::async_runtime::spawn(async move { + match runtime::registry::sync_core_skills(&engine_clone) { + Ok(sync) => { + log::info!( + "[registry] core sync complete: updated={}, skipped={}, errors={}", + sync.updated_core.len(), + sync.skipped_core.len(), + sync.errors.len() + ); + for error in sync.errors { + log::error!("[registry] core sync error: {}", error); + } + } + Err(e) => { + log::error!("[registry] core sync failed: {}", e); + } + } engine_clone.auto_start_skills().await; }); @@ -1013,6 +1030,11 @@ pub fn run() { ai_write_memory_file, ai_list_memory_files, // Runtime commands + registry_sync_core, + registry_list_catalog, + registry_install_skill, + registry_update_skill, + registry_uninstall_skill, runtime_discover_skills, runtime_list_skills, runtime_start_skill, @@ -1144,6 +1166,11 @@ pub fn run() { ai_write_memory_file, ai_list_memory_files, // Runtime commands + registry_sync_core, + registry_list_catalog, + registry_install_skill, + registry_update_skill, + registry_uninstall_skill, runtime_discover_skills, runtime_list_skills, runtime_start_skill, diff --git a/src-tauri/src/runtime/mod.rs b/src-tauri/src/runtime/mod.rs index a8fdbb7732..21b0dc699d 100644 --- a/src-tauri/src/runtime/mod.rs +++ b/src-tauri/src/runtime/mod.rs @@ -22,6 +22,8 @@ pub mod cron_scheduler; #[cfg(not(any(target_os = "android", target_os = "ios")))] pub mod ping_scheduler; #[cfg(not(any(target_os = "android", target_os = "ios")))] +pub mod registry; +#[cfg(not(any(target_os = "android", target_os = "ios")))] pub mod skill_registry; #[cfg(not(any(target_os = "android", target_os = "ios")))] pub mod qjs_engine; diff --git a/src-tauri/src/runtime/qjs_engine.rs b/src-tauri/src/runtime/qjs_engine.rs index 278a6e24fa..1f1f877c2c 100644 --- a/src-tauri/src/runtime/qjs_engine.rs +++ b/src-tauri/src/runtime/qjs_engine.rs @@ -15,7 +15,7 @@ use crate::runtime::ping_scheduler::PingScheduler; use crate::runtime::preferences::PreferencesStore; use crate::runtime::skill_registry::SkillRegistry; use crate::runtime::socket_manager::SocketManager; -use crate::runtime::types::{events, SkillMessage, SkillSnapshot, SkillStatus, ToolResult}; +use crate::runtime::types::{events, SkillSnapshot, SkillStatus, ToolResult}; use crate::runtime::qjs_skill_instance::{BridgeDeps, QjsSkillInstance}; // IdbStorage removed during runtime cleanup @@ -119,60 +119,15 @@ impl RuntimeEngine { log::info!("[runtime] Using explicit skills source dir: {:?}", dir); return Ok(dir.clone()); } - - let current = - std::env::current_dir().map_err(|e| format!("Failed to get current dir: {e}"))?; - - // 2. Dev: cwd/skills/skills - let dev_skills = current.join("skills").join("skills"); - if dev_skills.exists() { - log::info!("[runtime] Using dev skills dir: {:?}", dev_skills); - return Ok(dev_skills); - } - - // 3. Dev: ../skills/skills - if let Some(parent) = current.parent() { - let parent_skills = parent.join("skills").join("skills"); - if parent_skills.exists() { - log::info!("[runtime] Using parent dev skills dir: {:?}", parent_skills); - return Ok(parent_skills); - } - } - - // 4. Production: bundled resources - if let Some(resource_dir) = self.resource_dir.read().as_ref() { - let bundled_skills = resource_dir.join("_up_").join("skills").join("skills"); - if bundled_skills.exists() { - log::info!( - "[runtime] Using bundled skills from resources: {:?}", - bundled_skills - ); - return Ok(bundled_skills); - } - - let bundled_skills_alt = resource_dir.join("skills"); - if bundled_skills_alt.exists() { - log::info!( - "[runtime] Using bundled skills from resources (alt): {:?}", - bundled_skills_alt - ); - return Ok(bundled_skills_alt); - } - - log::warn!( - "[runtime] Resource dir set but skills not found. Checked: {:?} and {:?}", - bundled_skills, - bundled_skills_alt - ); - } - - // 5. Final fallback: app data dir - let prod_dir = self.skills_data_dir.clone(); + // Runtime-managed install directory fallback. + let install_dir = self.skills_data_dir.join("installed"); + std::fs::create_dir_all(&install_dir) + .map_err(|e| format!("Failed to create install dir {}: {e}", install_dir.display()))?; log::info!( - "[runtime] Skills source dir (data dir fallback): {:?}", - prod_dir + "[runtime] Skills source dir (runtime install fallback): {:?}", + install_dir ); - Ok(prod_dir) + Ok(install_dir) } /// Expose the resolved skills source directory (for external callers like unified registry). diff --git a/src-tauri/src/runtime/registry.rs b/src-tauri/src/runtime/registry.rs new file mode 100644 index 0000000000..699b34b056 --- /dev/null +++ b/src-tauri/src/runtime/registry.rs @@ -0,0 +1,475 @@ +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::process::Command; + +use crate::runtime::qjs_engine::RuntimeEngine; + +const SKILLS_REPO_URL: &str = "https://github.com/tinyhumansai/openhuman-skills.git"; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RegistryManifest { + #[serde(default)] + pub core_skills: Vec, + #[serde(default)] + pub contributor_skills: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RegistrySkillSpec { + pub id: String, + #[serde(default)] + pub name: Option, + #[serde(default)] + pub description: Option, + #[serde(default)] + pub version: Option, + #[serde(default)] + pub commit: Option, + #[serde(default)] + pub sha256: Option, + #[serde(default)] + pub path: Option, + #[serde(default)] + pub source_path: Option, + #[serde(default)] + pub manifest_path: Option, + #[serde(default)] + pub entry_path: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct RegistryState { + #[serde(default)] + pub installed: HashMap, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct InstalledSkillRecord { + pub id: String, + pub hash: String, + pub core: bool, + pub version: Option, + pub commit: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RegistryCatalogEntry { + pub id: String, + pub name: String, + pub description: String, + pub version: String, + pub core: bool, + pub installed: bool, + pub update_available: bool, + pub can_uninstall: bool, + pub commit: Option, + pub sha256: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RegistrySyncResult { + pub repo_path: String, + pub updated_core: Vec, + pub skipped_core: Vec, + pub errors: Vec, +} + +fn skills_repo_dir() -> Result { + if let Ok(path) = std::env::var("OPENHUMAN_SKILLS_DIR") { + let trimmed = path.trim(); + if !trimmed.is_empty() { + return Ok(PathBuf::from(trimmed)); + } + } + + let cwd = std::env::current_dir().map_err(|e| format!("Failed to resolve cwd: {e}"))?; + let direct = cwd.join("skills"); + if direct.exists() { + return Ok(direct); + } + + if cwd.file_name().and_then(|n| n.to_str()) == Some("src-tauri") { + return Ok(cwd.join("..").join("skills")); + } + + Ok(direct) +} + +fn registry_manifest_path(repo_dir: &Path) -> PathBuf { + repo_dir.join("registry").join("manifest.json") +} + +fn ensure_skills_repo(repo_dir: &Path) -> Result<(), String> { + if !repo_dir.exists() { + if let Some(parent) = repo_dir.parent() { + std::fs::create_dir_all(parent) + .map_err(|e| format!("Failed to create parent dir {}: {e}", parent.display()))?; + } + + let output = Command::new("git") + .args(["clone", "--depth", "1", SKILLS_REPO_URL]) + .arg(repo_dir) + .output() + .map_err(|e| format!("Failed to run git clone: {e}"))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(format!("Failed to clone skills repo: {stderr}")); + } + + return Ok(()); + } + + if !repo_dir.join(".git").exists() { + return Ok(()); + } + + let output = Command::new("git") + .arg("-C") + .arg(repo_dir) + .args(["pull", "--ff-only"]) + .output() + .map_err(|e| format!("Failed to run git pull: {e}"))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + log::warn!( + "[registry] git pull failed for {}: {}", + repo_dir.display(), + stderr + ); + } + + Ok(()) +} + +fn load_manifest(repo_dir: &Path) -> Result { + let path = registry_manifest_path(repo_dir); + let content = std::fs::read_to_string(&path) + .map_err(|e| format!("Failed to read registry manifest {}: {e}", path.display()))?; + serde_json::from_str(&content) + .map_err(|e| format!("Failed to parse registry manifest {}: {e}", path.display())) +} + +fn install_root(engine: &RuntimeEngine) -> Result { + let dir = engine.skills_source_dir()?; + std::fs::create_dir_all(&dir) + .map_err(|e| format!("Failed to create install root {}: {e}", dir.display()))?; + Ok(dir) +} + +fn state_path(engine: &RuntimeEngine) -> Result { + let install = install_root(engine)?; + let base = install + .parent() + .map(Path::to_path_buf) + .unwrap_or_else(|| install.clone()); + let state_dir = base.join("state"); + std::fs::create_dir_all(&state_dir) + .map_err(|e| format!("Failed to create state dir {}: {e}", state_dir.display()))?; + Ok(state_dir.join("registry-state.json")) +} + +fn load_state(engine: &RuntimeEngine) -> Result { + let path = state_path(engine)?; + if !path.exists() { + return Ok(RegistryState::default()); + } + let raw = std::fs::read_to_string(&path) + .map_err(|e| format!("Failed to read registry state {}: {e}", path.display()))?; + serde_json::from_str(&raw) + .map_err(|e| format!("Failed to parse registry state {}: {e}", path.display())) +} + +fn save_state(engine: &RuntimeEngine, state: &RegistryState) -> Result<(), String> { + let path = state_path(engine)?; + let json = serde_json::to_string_pretty(state) + .map_err(|e| format!("Failed to encode registry state: {e}"))?; + std::fs::write(&path, json) + .map_err(|e| format!("Failed to write registry state {}: {e}", path.display())) +} + +fn collect_files_recursive(root: &Path, files: &mut Vec) -> Result<(), String> { + let mut entries = std::fs::read_dir(root) + .map_err(|e| format!("Failed to read dir {}: {e}", root.display()))? + .flatten() + .map(|e| e.path()) + .collect::>(); + + entries.sort(); + + for path in entries { + if path.is_dir() { + collect_files_recursive(&path, files)?; + } else if path.is_file() { + files.push(path); + } + } + + Ok(()) +} + +fn hash_dir(path: &Path) -> Result { + let mut files = Vec::new(); + collect_files_recursive(path, &mut files)?; + + let mut hasher = Sha256::new(); + for file in files { + let rel = file + .strip_prefix(path) + .map_err(|e| format!("Failed to strip prefix {}: {e}", file.display()))?; + hasher.update(rel.to_string_lossy().as_bytes()); + let data = std::fs::read(&file) + .map_err(|e| format!("Failed to read file {} for hash: {e}", file.display()))?; + hasher.update(&data); + } + + Ok(hex::encode(hasher.finalize())) +} + +fn resolve_skill_source_dir(repo_dir: &Path, spec: &RegistrySkillSpec) -> PathBuf { + if let Some(path) = spec.path.as_ref().or(spec.source_path.as_ref()) { + return repo_dir.join(path); + } + + repo_dir.join("skills").join(&spec.id) +} + +fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<(), String> { + if dst.exists() { + std::fs::remove_dir_all(dst) + .map_err(|e| format!("Failed to clear existing dir {}: {e}", dst.display()))?; + } + std::fs::create_dir_all(dst) + .map_err(|e| format!("Failed to create destination dir {}: {e}", dst.display()))?; + + let mut files = Vec::new(); + collect_files_recursive(src, &mut files)?; + + for file in files { + let rel = file + .strip_prefix(src) + .map_err(|e| format!("Failed to strip prefix {}: {e}", file.display()))?; + let target = dst.join(rel); + if let Some(parent) = target.parent() { + std::fs::create_dir_all(parent) + .map_err(|e| format!("Failed to create parent dir {}: {e}", parent.display()))?; + } + std::fs::copy(&file, &target).map_err(|e| { + format!( + "Failed to copy {} -> {}: {e}", + file.display(), + target.display() + ) + })?; + } + + Ok(()) +} + +fn install_one_skill( + repo_dir: &Path, + install_root: &Path, + state: &mut RegistryState, + spec: &RegistrySkillSpec, + core: bool, +) -> Result { + let expected_hash = spec + .sha256 + .as_ref() + .filter(|s| !s.is_empty()) + .cloned() + .ok_or_else(|| format!("Skill '{}' is missing required sha256", spec.id))?; + + let src_dir = resolve_skill_source_dir(repo_dir, spec); + if !src_dir.exists() { + return Err(format!( + "Skill '{}' source directory not found: {}", + spec.id, + src_dir.display() + )); + } + + let computed_hash = hash_dir(&src_dir)?; + if computed_hash != expected_hash { + return Err(format!( + "Hash mismatch for skill '{}': expected {}, got {}", + spec.id, expected_hash, computed_hash + )); + } + + let already = state.installed.get(&spec.id); + let needs_update = already + .map(|r| r.hash != expected_hash || r.core != core) + .unwrap_or(true) + || !install_root.join(&spec.id).exists(); + + if !needs_update { + return Ok(false); + } + + let target_dir = install_root.join(&spec.id); + let staging = install_root.join(format!(".staging-{}", spec.id)); + copy_dir_recursive(&src_dir, &staging)?; + + let manifest_path = staging.join("manifest.json"); + if !manifest_path.exists() { + let _ = std::fs::remove_dir_all(&staging); + return Err(format!( + "Installed skill '{}' missing manifest.json in staged output", + spec.id + )); + } + + if target_dir.exists() { + std::fs::remove_dir_all(&target_dir) + .map_err(|e| format!("Failed to remove old skill dir {}: {e}", target_dir.display()))?; + } + + std::fs::rename(&staging, &target_dir) + .map_err(|e| format!("Failed to move staged skill into place: {e}"))?; + + state.installed.insert( + spec.id.clone(), + InstalledSkillRecord { + id: spec.id.clone(), + hash: expected_hash, + core, + version: spec.version.clone(), + commit: spec.commit.clone(), + }, + ); + + Ok(true) +} + +fn remove_installed_skill(engine: &RuntimeEngine, skill_id: &str) -> Result<(), String> { + let install_root = install_root(engine)?; + let skill_dir = install_root.join(skill_id); + if skill_dir.exists() { + std::fs::remove_dir_all(&skill_dir) + .map_err(|e| format!("Failed to remove installed skill {}: {e}", skill_dir.display()))?; + } + Ok(()) +} + +pub fn sync_core_skills(engine: &RuntimeEngine) -> Result { + let repo_dir = skills_repo_dir()?; + ensure_skills_repo(&repo_dir)?; + + let manifest = load_manifest(&repo_dir)?; + let install_root = install_root(engine)?; + let mut state = load_state(engine)?; + + let mut updated_core = Vec::new(); + let mut skipped_core = Vec::new(); + let mut errors = Vec::new(); + + for spec in &manifest.core_skills { + match install_one_skill(&repo_dir, &install_root, &mut state, spec, true) { + Ok(true) => updated_core.push(spec.id.clone()), + Ok(false) => skipped_core.push(spec.id.clone()), + Err(e) => errors.push(e), + } + } + + save_state(engine, &state)?; + + Ok(RegistrySyncResult { + repo_path: repo_dir.to_string_lossy().to_string(), + updated_core, + skipped_core, + errors, + }) +} + +pub fn list_catalog(engine: &RuntimeEngine) -> Result, String> { + let repo_dir = skills_repo_dir()?; + ensure_skills_repo(&repo_dir)?; + let manifest = load_manifest(&repo_dir)?; + let state = load_state(engine)?; + + let mut entries = Vec::new(); + + let mut push_entries = |specs: &Vec, core: bool| { + for spec in specs { + let installed_record = state.installed.get(&spec.id); + let expected_hash = spec.sha256.clone().unwrap_or_default(); + let update_available = installed_record + .map(|r| r.hash != expected_hash) + .unwrap_or(false); + + entries.push(RegistryCatalogEntry { + id: spec.id.clone(), + name: spec + .name + .clone() + .unwrap_or_else(|| spec.id.clone()), + description: spec.description.clone().unwrap_or_default(), + version: spec.version.clone().unwrap_or_else(|| "0.0.0".to_string()), + core, + installed: installed_record.is_some(), + update_available, + can_uninstall: !core, + commit: spec.commit.clone(), + sha256: spec.sha256.clone(), + }); + } + }; + + push_entries(&manifest.core_skills, true); + push_entries(&manifest.contributor_skills, false); + + entries.sort_by(|a, b| { + a.core + .cmp(&b.core) + .reverse() + .then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase())) + }); + + Ok(entries) +} + +fn find_skill_spec<'a>(manifest: &'a RegistryManifest, skill_id: &str) -> Option<(&'a RegistrySkillSpec, bool)> { + if let Some(spec) = manifest.core_skills.iter().find(|s| s.id == skill_id) { + return Some((spec, true)); + } + manifest + .contributor_skills + .iter() + .find(|s| s.id == skill_id) + .map(|s| (s, false)) +} + +pub fn install_or_update_skill(engine: &RuntimeEngine, skill_id: &str) -> Result<(), String> { + let repo_dir = skills_repo_dir()?; + ensure_skills_repo(&repo_dir)?; + let manifest = load_manifest(&repo_dir)?; + let (spec, core) = find_skill_spec(&manifest, skill_id) + .ok_or_else(|| format!("Skill '{}' not found in registry", skill_id))?; + + let install_root = install_root(engine)?; + let mut state = load_state(engine)?; + install_one_skill(&repo_dir, &install_root, &mut state, spec, core)?; + save_state(engine, &state) +} + +pub fn uninstall_skill(engine: &RuntimeEngine, skill_id: &str) -> Result<(), String> { + let repo_dir = skills_repo_dir()?; + ensure_skills_repo(&repo_dir)?; + let manifest = load_manifest(&repo_dir)?; + let (_, core) = find_skill_spec(&manifest, skill_id) + .ok_or_else(|| format!("Skill '{}' not found in registry", skill_id))?; + + if core { + return Err(format!("Skill '{}' is core and cannot be uninstalled", skill_id)); + } + + remove_installed_skill(engine, skill_id)?; + + let mut state = load_state(engine)?; + state.installed.remove(skill_id); + save_state(engine, &state) +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index e3922abda0..37d963ccac 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -38,7 +38,6 @@ "icons/icon.ico" ], "resources": [ - "../skills/skills", "ai" ], "createUpdaterArtifacts": true, diff --git a/src/components/SkillsGrid.tsx b/src/components/SkillsGrid.tsx index f147285408..ccc7199f33 100644 --- a/src/components/SkillsGrid.tsx +++ b/src/components/SkillsGrid.tsx @@ -16,30 +16,6 @@ import { } from './skills/shared'; import SkillSetupModal from './skills/SkillSetupModal'; -/** Normalize a raw unified registry entry into a SkillListEntry for display. */ -function normalizeUnifiedEntry(e: Record): SkillListEntry { - const setup = e.setup as { required?: boolean; oauth?: unknown } | undefined; - // Treat both interactive setup steps and OAuth-only flows as "has setup" - // so that clicking a skill (e.g. Gmail) opens the connection/setup wizard - // instead of jumping straight to the management panel. - const hasSetup = - !!setup && - (setup.required === true || - // OAuth config means we still need a connection step in the wizard - !!setup.oauth); - - return { - id: e.id as string, - name: - (e.name as string) || (e.id as string).charAt(0).toUpperCase() + (e.id as string).slice(1), - description: (e.description as string) || '', - icon: SKILL_ICONS[e.id as string], - ignoreInProduction: (e.ignoreInProduction as boolean) ?? false, - hasSetup, - skill_type: (e.skill_type as 'openhuman' | 'openclaw') ?? 'openhuman', - }; -} - interface SkillRowProps { skillId: string; name: string; @@ -129,26 +105,33 @@ export default function SkillsGrid() { // Extracted so it can be called after skill creation (e.g. from SelfEvolveModal). const refreshSkills = async () => { try { - // Try unified registry first — it merges both skill types. - const entries = await invoke>>('unified_list_skills'); - - const processed: SkillListEntry[] = entries - .filter(e => { - const id = e.id as string; - if (id.includes('_')) { - console.warn( - `Skill "${id}" contains underscore and will be skipped. Skill IDs cannot contain underscores.` - ); - return false; - } - return true; + const catalog = await invoke>>('registry_list_catalog'); + const installedIds = new Set( + catalog.filter(entry => entry.installed === true).map(entry => entry.id as string) + ); + const manifests = await invoke>>('runtime_discover_skills'); + const processed: SkillListEntry[] = manifests + .filter(m => { + const id = m.id as string; + if (id.includes('_')) return false; + return installedIds.size === 0 || installedIds.has(id); + }) + .map(m => { + const setup = m.setup as { required?: boolean; oauth?: unknown } | undefined; + const hasSetup = !!setup && (setup.required === true || !!setup.oauth); + return { + id: m.id as string, + name: (m.name as string) || (m.id as string), + description: (m.description as string) || '', + icon: SKILL_ICONS[m.id as string], + ignoreInProduction: (m.ignoreInProduction as boolean) ?? false, + hasSetup, + skill_type: 'openhuman' as const, + }; }) - .map(normalizeUnifiedEntry) .filter(s => IS_DEV || !s.ignoreInProduction); - setSkillsList(processed); } catch { - // Fallback to legacy runtime_discover_skills if unified registry isn't available. try { const manifests = await invoke>>('runtime_discover_skills'); const processed: SkillListEntry[] = manifests diff --git a/src/lib/skills/paths.ts b/src/lib/skills/paths.ts index c3f00ae43d..96920c3799 100644 --- a/src/lib/skills/paths.ts +++ b/src/lib/skills/paths.ts @@ -10,14 +10,14 @@ import { IS_DEV } from "../../utils/config"; /** * Get the root directory for discovering skills. - * In dev, skills are in the submodule `skills/skills/` dir. - * In production, paths are resolved by the Rust engine. + * Skills are installed at runtime by the Rust registry service. + * Client-side path helpers are informational only. */ export function getSkillsBaseDir(): string { if (IS_DEV) { - return "skills"; + return "skills/installed"; } - return ""; + return "skills/installed"; } /** @@ -32,8 +32,5 @@ export function getSkillModulePath(skillId: string): string { * Get the manifest path for a skill. */ export function getSkillManifestPath(skillId: string): string { - if (IS_DEV) { - return `skills/skills/${skillId}/manifest.json`; - } - return `skills/${skillId}/manifest.json`; + return `skills/installed/${skillId}/manifest.json`; } diff --git a/src/pages/Skills.tsx b/src/pages/Skills.tsx index f6f14f9df5..d19a89e62f 100644 --- a/src/pages/Skills.tsx +++ b/src/pages/Skills.tsx @@ -18,6 +18,17 @@ import { useAppSelector } from '../store/hooks'; import { IS_DEV } from '../utils/config'; import { deriveSkillSyncUiState } from './skillsSyncUi'; +interface RegistryCatalogEntry { + id: string; + name: string; + description: string; + version: string; + core: boolean; + installed: boolean; + update_available: boolean; + can_uninstall: boolean; +} + /** Format large numbers: 1200 → "1.2K", 1200000 → "1.2M" */ function formatNumber(n: number): string { if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; @@ -188,6 +199,8 @@ function SkillCard({ skill, onSetup }: SkillCardProps) { export default function Skills() { // Skills state const [skillsList, setSkillsList] = useState([]); + const [catalog, setCatalog] = useState([]); + const [catalogBusy, setCatalogBusy] = useState>({}); const [skillsLoading, setSkillsLoading] = useState(true); const skillsState = useAppSelector(state => state.skills.skills); const skillStates = useAppSelector(state => state.skills.skillStates); @@ -214,12 +227,20 @@ export default function Skills() { // not Tauri env } + let catalogEntries: RegistryCatalogEntry[] = []; + try { + catalogEntries = await invoke('registry_list_catalog'); + } catch (err) { + console.warn('[Skills] failed to load registry catalog', err); + } + setCatalog(catalogEntries); + const manifests = await invoke>>('runtime_discover_skills'); - const ALLOWED_SKILLS = new Set(['gmail', 'notion']); const validManifests = manifests.filter(m => { const id = m.id as string; if (id.includes('_')) return false; - return ALLOWED_SKILLS.has(id); + if (catalogEntries.length === 0) return true; + return catalogEntries.some(c => c.id === id && c.installed); }); const processed: SkillListEntry[] = validManifests @@ -233,7 +254,7 @@ export default function Skills() { description: (m.description as string) || '', icon: SKILL_ICONS[m.id as string], ignoreInProduction: (m.ignoreInProduction as boolean) ?? false, - hasSetup: !!(setup && setup.required), + hasSetup: !!(setup && (setup.required || setup.oauth)), }; }) .filter(s => IS_DEV || !s.ignoreInProduction); @@ -248,6 +269,43 @@ export default function Skills() { loadSkills(); }, []); + const refreshSkills = async () => { + setSkillsLoading(true); + try { + const catalogEntries = await invoke('registry_list_catalog'); + setCatalog(catalogEntries); + const manifests = await invoke>>('runtime_discover_skills'); + const validManifests = manifests.filter(m => { + const id = m.id as string; + if (id.includes('_')) return false; + if (catalogEntries.length === 0) return true; + return catalogEntries.some(c => c.id === id && c.installed); + }); + + const processed: SkillListEntry[] = validManifests + .map(m => { + const setup = m.setup as Record | undefined; + return { + id: m.id as string, + name: + (m.name as string) || + (m.id as string).charAt(0).toUpperCase() + (m.id as string).slice(1), + description: (m.description as string) || '', + icon: SKILL_ICONS[m.id as string], + ignoreInProduction: (m.ignoreInProduction as boolean) ?? false, + hasSetup: !!(setup && (setup.required || setup.oauth)), + }; + }) + .filter(s => IS_DEV || !s.ignoreInProduction); + + setSkillsList(processed); + } catch (err) { + console.warn('[Skills] refresh failed', err); + } finally { + setSkillsLoading(false); + } + }; + // Sort skills by connection status const sortedSkillsList = useMemo(() => { return [...skillsList] @@ -277,6 +335,27 @@ export default function Skills() { setSetupModalOpen(true); }; + const handleRegistryAction = async ( + skillId: string, + action: 'install' | 'update' | 'uninstall' + ) => { + setCatalogBusy(prev => ({ ...prev, [skillId]: true })); + try { + if (action === 'install') { + await invoke('registry_install_skill', { skill_id: skillId }); + } else if (action === 'update') { + await invoke('registry_update_skill', { skill_id: skillId }); + } else { + await invoke('registry_uninstall_skill', { skill_id: skillId }); + } + await refreshSkills(); + } catch (err) { + console.warn(`[Skills] registry ${action} failed for ${skillId}:`, err); + } finally { + setCatalogBusy(prev => ({ ...prev, [skillId]: false })); + } + }; + return (
@@ -309,6 +388,79 @@ export default function Skills() {
)}
+ +
+
+

Skills Registry

+
+ {catalog.length === 0 ? ( +
+

No registry entries found

+
+ ) : ( +
+ {catalog.map(entry => { + const busy = !!catalogBusy[entry.id]; + return ( +
+
+
+ {entry.name} + + {entry.core ? 'Core' : 'Contributor'} + + {entry.update_available && ( + + Update Available + + )} +
+

+ {entry.description || 'No description'} +

+
+
+ {!entry.installed ? ( + + ) : ( + <> + {entry.update_available && ( + + )} + {entry.can_uninstall && ( + + )} + + )} +
+
+ ); + })} +
+ )} +
diff --git a/src/providers/SkillProvider.tsx b/src/providers/SkillProvider.tsx index d0905853a1..94b0c6c5cc 100644 --- a/src/providers/SkillProvider.tsx +++ b/src/providers/SkillProvider.tsx @@ -20,6 +20,12 @@ import { DEV_AUTO_LOAD_SKILL, IS_DEV } from '../utils/config'; // --------------------------------------------------------------------------- async function discoverSkills(): Promise { + try { + await invoke('registry_sync_core'); + } catch (err) { + console.warn('[SkillProvider] registry_sync_core failed:', err); + } + const raw = await invoke>>('runtime_discover_skills'); const manifests: SkillManifest[] = raw.map(m => ({ diff --git a/src/utils/tauriCommands.ts b/src/utils/tauriCommands.ts index 5226d59d53..8031671310 100644 --- a/src/utils/tauriCommands.ts +++ b/src/utils/tauriCommands.ts @@ -410,6 +410,26 @@ export interface RuntimeFlags { log_prompts: boolean; } +export interface RegistrySyncResult { + repo_path: string; + updated_core: string[]; + skipped_core: string[]; + errors: string[]; +} + +export interface RegistryCatalogEntry { + id: string; + name: string; + description: string; + version: string; + core: boolean; + installed: boolean; + update_available: boolean; + can_uninstall: boolean; + commit?: string | null; + sha256?: string | null; +} + export interface TunnelConfig { provider: string; cloudflare?: { token: string } | null; @@ -683,3 +703,38 @@ export async function runtimeDisableSkill(skillId: string): Promise { } await invoke('runtime_disable_skill', { skill_id: skillId }); } + +export async function registrySyncCore(): Promise { + if (!isTauri()) { + throw new Error('Not running in Tauri'); + } + return await invoke('registry_sync_core'); +} + +export async function registryListCatalog(): Promise { + if (!isTauri()) { + throw new Error('Not running in Tauri'); + } + return await invoke('registry_list_catalog'); +} + +export async function registryInstallSkill(skillId: string): Promise { + if (!isTauri()) { + throw new Error('Not running in Tauri'); + } + await invoke('registry_install_skill', { skill_id: skillId }); +} + +export async function registryUpdateSkill(skillId: string): Promise { + if (!isTauri()) { + throw new Error('Not running in Tauri'); + } + await invoke('registry_update_skill', { skill_id: skillId }); +} + +export async function registryUninstallSkill(skillId: string): Promise { + if (!isTauri()) { + throw new Error('Not running in Tauri'); + } + await invoke('registry_uninstall_skill', { skill_id: skillId }); +} From b416b6cb44d94d447f817046df7ee9c9142b524e Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 27 Mar 2026 11:56:40 -0700 Subject: [PATCH 2/2] update todo --- docs/TODO.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/TODO.md b/docs/TODO.md index 56c5150f7c..d81976f858 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -18,3 +18,4 @@ todo [] - add a local model that can read through the screen and also go through voice using an API like whisper [] - add a screener recorder that goes through the intefaces in the screen and locally summarizes what is happening and brings more assitance to the user [] clean up the core so that we can run it as a binary on a server or as docker +[] we need to get reverse tunneling solutions added