mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-31 16:49:34 +00:00
Fix skills system: enable by default, fix registry and install (#300)
* feat: add Docker detection module with platform guidance Co-Authored-By: Claude Opus 4.6 <[email protected]> * feat: add Docker sandbox step to setup wizard Co-Authored-By: Claude Opus 4.6 <[email protected]> * feat: show Docker status in boot screen Co-Authored-By: Claude Opus 4.6 <[email protected]> * feat: check Docker availability at startup When SANDBOX_ENABLED=true, proactively detect whether Docker is installed and running before creating the ContainerJobManager. If Docker is unavailable, log a warning with platform-specific guidance and disable the sandbox for the session. Co-Authored-By: Claude Opus 4.6 <[email protected]> * feat: enable sandbox by default, improve wizard explanation, document detection limits - SandboxConfig defaults to enabled=true (startup check disables gracefully if Docker is unavailable) - Wizard step explains why Docker matters: isolation for LLM-generated code vs running directly on the host - Document detection confidence per platform in detect.rs module docs: high on macOS/Linux, medium on Windows (named pipe edge cases) Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: cargo fmt + update test_builder_defaults for enabled-by-default Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: deduplicate wizard Docker status handling per review Co-Authored-By: Claude Opus 4.6 <[email protected]> * feat: fix skills system - enable by default, fix registry connectivity and install - Enable skills system by default (SKILLS_ENABLED no longer required) - Bypass Vercel TLS fingerprint blocking by pointing DEFAULT_REGISTRY_URL directly at the Convex backend (wry-manatee-359.convex.site) - Handle ZIP archives from ClawHub download API - the registry returns ZIP files containing SKILL.md, not raw text. Uses flate2 (existing dep) to extract SKILL.md from the archive. - Surface catalog search errors in the UI with a yellow warning banner instead of silently returning empty results - Handle both {"results":[...]} envelope and bare [...] array JSON formats from the search API - Add ClawHub links and metadata to search result cards (clickable skill names linking to clawhub.ai, relevance score, "updated X ago" recency) - Fix 3 pre-existing clippy warnings in tests/html_to_markdown.rs Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: address security review feedback on ZIP extraction and SSRF - Cap download size to 10 MB before reading response body - Guard against ZIP bombs: cap uncompressed_size at 1 MB, wrap DeflateDecoder with .take() read limit - Use checked_add for ZIP header offset arithmetic to prevent overflow - Remove .unwrap() on try_into() -- use direct array construction - Handle IPv4-mapped IPv6 addresses (::ffff:192.168.x.x) in SSRF checks - Don't leak internal registry URLs in user-facing catalog_error messages - Fix non-ASCII panic in catalog response debug logging (use .get() instead of byte slicing) Co-Authored-By: Claude Opus 4.6 <[email protected]> * feat: add /skills command and enrich search results with ClawHub metadata - Parse /skills and /skills search <query> as SystemCommands in submission.rs - Add skill_catalog to AgentDeps and wire it through main.rs - Handle "skills" command in commands.rs: list installed skills and search ClawHub - Add /skills and /skills search <q> entries to /help output - Add SkillDetail, SkillStats, SkillOwner structs to catalog.rs - Add fetch_skill_detail() calling GET /api/v1/skills/{slug} on Convex backend - Add enrich_search_results() to fetch stars/downloads/owner for top 5 results in parallel - Fix SkillDetailResponse wrapper struct to match actual API shape: {"skill":{...},"owner":{...}} - Surface stars, downloads, owner in web UI skill search cards (app.js) - Surface enriched data in skills web handler and skill_search tool output Co-Authored-By: Claude Sonnet 4.6 <[email protected]> * fix: cargo fmt after merge conflict resolution Co-Authored-By: Claude Sonnet 4.6 <[email protected]> * fix: separate installed_skills dir for correct trust on restart, remove duplicate handlers Trust level bug: skills installed from ClawHub were written to user_dir (~/.ironclaw/skills/) which is discovered as Trusted on restart. Now installs go to ~/.ironclaw/installed_skills/ which is discovered as Installed, matching the documented skill directory layout. Changes: - SkillsConfig: add installed_dir field (SKILLS_INSTALLED_DIR env var, default ~/.ironclaw/installed_skills/) - SkillRegistry: add with_installed_dir() builder, installed_dir()/ install_target_dir() accessors, and discover installed_dir with SkillTrust::Installed in discover_all() - All install paths (web handler, skill tool) use install_target_dir() instead of user_dir() so new installs land in the correct directory - 3 new registry tests: test_installed_dir_uses_installed_trust, test_install_target_dir_prefers_installed_dir, test_user_dir_stays_trusted_with_installed_dir Duplicate handler cleanup: handlers/skills.rs was the canonical implementation but the handlers module was never compiled (not declared in web/mod.rs), so server.rs had its own duplicate inline definitions that the router used. Wire up the handlers module, delete the 260-line duplicate in server.rs, and have server.rs import skills handlers from handlers::skills. Fix pre-existing compile error in handlers/extensions.rs (missing needs_setup field). Add #[allow(dead_code)] on not-yet-migrated handler modules to suppress warnings. Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: probe more Docker socket paths on macOS Docker Desktop 4.13+ (stabilised in 4.18) no longer creates the /var/run/docker.sock symlink by default. The API socket lives at ~/.docker/run/docker.sock, which bollard's connect_with_local_defaults() does not try. Add a fallback probe list covering the common macOS container runtimes: - ~/.docker/run/docker.sock — Docker Desktop 4.13+ - ~/.colima/default/docker.sock — Colima - ~/.rd/docker.sock — Rancher Desktop Remove the bogus ~/.docker/desktop/docker.sock path that was added previously; it is not an API socket on any known Docker installation. Fixes the false-negative "Docker is installed but not running" warning reported by Illia on macOS with Docker Desktop 4.18+. Co-Authored-By: Claude Sonnet 4.6 <[email protected]> * Harden Docker detection for rootless Linux and Windows fallback --------- Co-authored-by: Claude Opus 4.6 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
f4ba85ffa2
commit
4e2dd76ae5
@@ -34,6 +34,7 @@ pub async fn extensions_list_handler(
|
||||
authenticated: ext.authenticated,
|
||||
active: ext.active,
|
||||
tools: ext.tools,
|
||||
needs_setup: ext.needs_setup,
|
||||
})
|
||||
.collect();
|
||||
|
||||
|
||||
@@ -1,23 +1,28 @@
|
||||
//! Handler modules for the web gateway API.
|
||||
//!
|
||||
//! Each module groups related endpoint handlers by domain.
|
||||
//!
|
||||
//! # Migration status
|
||||
//!
|
||||
//! `skills` is the canonical implementation used by `server.rs`.
|
||||
//! The remaining modules are in-progress migrations from inline server.rs
|
||||
//! handlers; their functions are not yet wired up, hence the `dead_code` allow.
|
||||
|
||||
pub mod chat;
|
||||
pub mod extensions;
|
||||
pub mod jobs;
|
||||
pub mod memory;
|
||||
pub mod routines;
|
||||
pub mod settings;
|
||||
pub mod skills;
|
||||
pub mod static_files;
|
||||
|
||||
// Re-export all handler functions so `server.rs` can reference them
|
||||
// as `handlers::chat_send_handler`, etc.
|
||||
pub use chat::*;
|
||||
pub use extensions::*;
|
||||
pub use jobs::*;
|
||||
pub use memory::*;
|
||||
pub use routines::*;
|
||||
pub use settings::*;
|
||||
pub use skills::*;
|
||||
pub use static_files::*;
|
||||
// Modules not yet wired into server.rs router -- suppress dead_code until
|
||||
// they replace their inline counterparts.
|
||||
#[allow(dead_code)]
|
||||
pub mod chat;
|
||||
#[allow(dead_code)]
|
||||
pub mod extensions;
|
||||
#[allow(dead_code)]
|
||||
pub mod jobs;
|
||||
#[allow(dead_code)]
|
||||
pub mod memory;
|
||||
#[allow(dead_code)]
|
||||
pub mod routines;
|
||||
#[allow(dead_code)]
|
||||
pub mod settings;
|
||||
#[allow(dead_code)]
|
||||
pub mod static_files;
|
||||
|
||||
@@ -58,8 +58,14 @@ pub async fn skills_search_handler(
|
||||
))?;
|
||||
|
||||
// Search ClawHub catalog
|
||||
let catalog_results = catalog.search(&req.query).await;
|
||||
let catalog_json: Vec<serde_json::Value> = catalog_results
|
||||
let catalog_outcome = catalog.search(&req.query).await;
|
||||
let catalog_error = catalog_outcome.error.clone();
|
||||
|
||||
// Enrich top results with detail data (stars, downloads, owner)
|
||||
let mut entries = catalog_outcome.results;
|
||||
catalog.enrich_search_results(&mut entries, 5).await;
|
||||
|
||||
let catalog_json: Vec<serde_json::Value> = entries
|
||||
.into_iter()
|
||||
.map(|e| {
|
||||
serde_json::json!({
|
||||
@@ -68,6 +74,10 @@ pub async fn skills_search_handler(
|
||||
"description": e.description,
|
||||
"version": e.version,
|
||||
"score": e.score,
|
||||
"updatedAt": e.updated_at,
|
||||
"stars": e.stars,
|
||||
"downloads": e.downloads,
|
||||
"owner": e.owner,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
@@ -103,6 +113,7 @@ pub async fn skills_search_handler(
|
||||
catalog: catalog_json,
|
||||
installed,
|
||||
registry_url: catalog.registry_url().to_string(),
|
||||
catalog_error,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -147,7 +158,7 @@ pub async fn skills_install_handler(
|
||||
)));
|
||||
};
|
||||
|
||||
// Parse, check duplicates, and get user_dir under a brief read lock.
|
||||
// Parse, check duplicates, and get install_dir under a brief read lock.
|
||||
let (user_dir, skill_name_from_parse) = {
|
||||
let guard = registry.read().map_err(|e| {
|
||||
(
|
||||
@@ -168,7 +179,7 @@ pub async fn skills_install_handler(
|
||||
))));
|
||||
}
|
||||
|
||||
(guard.user_dir().to_path_buf(), skill_name)
|
||||
(guard.install_target_dir().to_path_buf(), skill_name)
|
||||
};
|
||||
|
||||
// Perform async I/O (write to disk, load) with no lock held.
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
//! ```
|
||||
|
||||
pub mod auth;
|
||||
pub(crate) mod handlers;
|
||||
pub mod log_layer;
|
||||
pub mod openai_compat;
|
||||
pub mod server;
|
||||
|
||||
+3
-247
@@ -28,6 +28,9 @@ use uuid::Uuid;
|
||||
use crate::agent::SessionManager;
|
||||
use crate::channels::IncomingMessage;
|
||||
use crate::channels::web::auth::{AuthState, auth_middleware};
|
||||
use crate::channels::web::handlers::skills::{
|
||||
skills_install_handler, skills_list_handler, skills_remove_handler, skills_search_handler,
|
||||
};
|
||||
use crate::channels::web::log_layer::LogBroadcaster;
|
||||
use crate::channels::web::sse::SseManager;
|
||||
use crate::channels::web::types::*;
|
||||
@@ -2086,253 +2089,6 @@ async fn pairing_approve_handler(
|
||||
}
|
||||
}
|
||||
|
||||
// --- Skills handlers ---
|
||||
|
||||
async fn skills_list_handler(
|
||||
State(state): State<Arc<GatewayState>>,
|
||||
) -> Result<Json<super::types::SkillListResponse>, (StatusCode, String)> {
|
||||
let registry = state.skill_registry.as_ref().ok_or((
|
||||
StatusCode::NOT_IMPLEMENTED,
|
||||
"Skills system not enabled".to_string(),
|
||||
))?;
|
||||
|
||||
let guard = registry.read().map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Skill registry lock poisoned: {}", e),
|
||||
)
|
||||
})?;
|
||||
|
||||
let skills: Vec<super::types::SkillInfo> = guard
|
||||
.skills()
|
||||
.iter()
|
||||
.map(|s| super::types::SkillInfo {
|
||||
name: s.manifest.name.clone(),
|
||||
description: s.manifest.description.clone(),
|
||||
version: s.manifest.version.clone(),
|
||||
trust: s.trust.to_string(),
|
||||
source: format!("{:?}", s.source),
|
||||
keywords: s.manifest.activation.keywords.clone(),
|
||||
})
|
||||
.collect();
|
||||
|
||||
let count = skills.len();
|
||||
Ok(Json(super::types::SkillListResponse { skills, count }))
|
||||
}
|
||||
|
||||
async fn skills_search_handler(
|
||||
State(state): State<Arc<GatewayState>>,
|
||||
Json(req): Json<super::types::SkillSearchRequest>,
|
||||
) -> Result<Json<super::types::SkillSearchResponse>, (StatusCode, String)> {
|
||||
let registry = state.skill_registry.as_ref().ok_or((
|
||||
StatusCode::NOT_IMPLEMENTED,
|
||||
"Skills system not enabled".to_string(),
|
||||
))?;
|
||||
|
||||
let catalog = state.skill_catalog.as_ref().ok_or((
|
||||
StatusCode::NOT_IMPLEMENTED,
|
||||
"Skill catalog not available".to_string(),
|
||||
))?;
|
||||
|
||||
// Search ClawHub catalog
|
||||
let catalog_results = catalog.search(&req.query).await;
|
||||
let catalog_json: Vec<serde_json::Value> = catalog_results
|
||||
.into_iter()
|
||||
.map(|e| {
|
||||
serde_json::json!({
|
||||
"slug": e.slug,
|
||||
"name": e.name,
|
||||
"description": e.description,
|
||||
"version": e.version,
|
||||
"score": e.score,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Search local skills
|
||||
let query_lower = req.query.to_lowercase();
|
||||
let installed: Vec<super::types::SkillInfo> = {
|
||||
let guard = registry.read().map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Skill registry lock poisoned: {}", e),
|
||||
)
|
||||
})?;
|
||||
guard
|
||||
.skills()
|
||||
.iter()
|
||||
.filter(|s| {
|
||||
s.manifest.name.to_lowercase().contains(&query_lower)
|
||||
|| s.manifest.description.to_lowercase().contains(&query_lower)
|
||||
})
|
||||
.map(|s| super::types::SkillInfo {
|
||||
name: s.manifest.name.clone(),
|
||||
description: s.manifest.description.clone(),
|
||||
version: s.manifest.version.clone(),
|
||||
trust: s.trust.to_string(),
|
||||
source: format!("{:?}", s.source),
|
||||
keywords: s.manifest.activation.keywords.clone(),
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
|
||||
Ok(Json(super::types::SkillSearchResponse {
|
||||
catalog: catalog_json,
|
||||
installed,
|
||||
registry_url: catalog.registry_url().to_string(),
|
||||
}))
|
||||
}
|
||||
|
||||
async fn skills_install_handler(
|
||||
State(state): State<Arc<GatewayState>>,
|
||||
headers: axum::http::HeaderMap,
|
||||
Json(req): Json<super::types::SkillInstallRequest>,
|
||||
) -> Result<Json<ActionResponse>, (StatusCode, String)> {
|
||||
// Require explicit confirmation header to prevent accidental installs.
|
||||
// Chat tools have requires_approval(); this is the equivalent for the web API.
|
||||
if headers
|
||||
.get("x-confirm-action")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
!= Some("true")
|
||||
{
|
||||
return Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
"Skill install requires X-Confirm-Action: true header".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let registry = state.skill_registry.as_ref().ok_or((
|
||||
StatusCode::NOT_IMPLEMENTED,
|
||||
"Skills system not enabled".to_string(),
|
||||
))?;
|
||||
|
||||
let content = if let Some(ref raw) = req.content {
|
||||
raw.clone()
|
||||
} else if let Some(ref url) = req.url {
|
||||
// Fetch from explicit URL (with SSRF protection)
|
||||
crate::tools::builtin::skill_tools::fetch_skill_content(url)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?
|
||||
} else if let Some(ref catalog) = state.skill_catalog {
|
||||
let url = crate::skills::catalog::skill_download_url(catalog.registry_url(), &req.name);
|
||||
crate::tools::builtin::skill_tools::fetch_skill_content(&url)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::BAD_GATEWAY, e.to_string()))?
|
||||
} else {
|
||||
return Ok(Json(ActionResponse::fail(
|
||||
"Provide 'content' or 'url' to install a skill".to_string(),
|
||||
)));
|
||||
};
|
||||
|
||||
// Parse, check duplicates, and get user_dir under a brief read lock.
|
||||
let (user_dir, skill_name_from_parse) = {
|
||||
let guard = registry.read().map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Skill registry lock poisoned: {}", e),
|
||||
)
|
||||
})?;
|
||||
|
||||
let normalized = crate::skills::normalize_line_endings(&content);
|
||||
let parsed = crate::skills::parser::parse_skill_md(&normalized)
|
||||
.map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?;
|
||||
let skill_name = parsed.manifest.name.clone();
|
||||
|
||||
if guard.has(&skill_name) {
|
||||
return Ok(Json(ActionResponse::fail(format!(
|
||||
"Skill '{}' already exists",
|
||||
skill_name
|
||||
))));
|
||||
}
|
||||
|
||||
(guard.user_dir().to_path_buf(), skill_name)
|
||||
};
|
||||
|
||||
// Perform async I/O (write to disk, load) with no lock held.
|
||||
let normalized = crate::skills::normalize_line_endings(&content);
|
||||
let (skill_name, loaded_skill) =
|
||||
crate::skills::registry::SkillRegistry::prepare_install_to_disk(
|
||||
&user_dir,
|
||||
&skill_name_from_parse,
|
||||
&normalized,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
// Commit: brief write lock for in-memory addition
|
||||
let mut guard = registry.write().map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Skill registry lock poisoned: {}", e),
|
||||
)
|
||||
})?;
|
||||
|
||||
match guard.commit_install(&skill_name, loaded_skill) {
|
||||
Ok(()) => Ok(Json(ActionResponse::ok(format!(
|
||||
"Skill '{}' installed",
|
||||
skill_name
|
||||
)))),
|
||||
Err(e) => Ok(Json(ActionResponse::fail(e.to_string()))),
|
||||
}
|
||||
}
|
||||
|
||||
async fn skills_remove_handler(
|
||||
State(state): State<Arc<GatewayState>>,
|
||||
headers: axum::http::HeaderMap,
|
||||
Path(name): Path<String>,
|
||||
) -> Result<Json<ActionResponse>, (StatusCode, String)> {
|
||||
// Require explicit confirmation header to prevent accidental removals.
|
||||
if headers
|
||||
.get("x-confirm-action")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
!= Some("true")
|
||||
{
|
||||
return Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
"Skill removal requires X-Confirm-Action: true header".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let registry = state.skill_registry.as_ref().ok_or((
|
||||
StatusCode::NOT_IMPLEMENTED,
|
||||
"Skills system not enabled".to_string(),
|
||||
))?;
|
||||
|
||||
// Validate removal under a brief read lock
|
||||
let skill_path = {
|
||||
let guard = registry.read().map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Skill registry lock poisoned: {}", e),
|
||||
)
|
||||
})?;
|
||||
guard
|
||||
.validate_remove(&name)
|
||||
.map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?
|
||||
};
|
||||
|
||||
// Delete files from disk (async I/O, no lock held)
|
||||
crate::skills::registry::SkillRegistry::delete_skill_files(&skill_path)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
// Remove from in-memory registry under a brief write lock
|
||||
let mut guard = registry.write().map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Skill registry lock poisoned: {}", e),
|
||||
)
|
||||
})?;
|
||||
|
||||
match guard.commit_remove(&name) {
|
||||
Ok(()) => Ok(Json(ActionResponse::ok(format!(
|
||||
"Skill '{}' removed",
|
||||
name
|
||||
)))),
|
||||
Err(e) => Ok(Json(ActionResponse::fail(e.to_string()))),
|
||||
}
|
||||
}
|
||||
|
||||
// --- Routines handlers ---
|
||||
|
||||
async fn routines_list_handler(
|
||||
|
||||
@@ -93,7 +93,11 @@ function apiFetch(path, options) {
|
||||
opts.body = JSON.stringify(opts.body);
|
||||
}
|
||||
return fetch(path, opts).then((res) => {
|
||||
if (!res.ok) throw new Error(res.status + ' ' + res.statusText);
|
||||
if (!res.ok) {
|
||||
return res.text().then(function(body) {
|
||||
throw new Error(body || (res.status + ' ' + res.statusText));
|
||||
});
|
||||
}
|
||||
return res.json();
|
||||
});
|
||||
}
|
||||
@@ -846,6 +850,7 @@ function switchTab(tab) {
|
||||
if (tab === 'routines') loadRoutines();
|
||||
if (tab === 'logs') applyLogFilters();
|
||||
if (tab === 'extensions') loadExtensions();
|
||||
if (tab === 'skills') loadSkills();
|
||||
}
|
||||
|
||||
// --- Memory (filesystem tree) ---
|
||||
@@ -2713,6 +2718,328 @@ function addMcpServer() {
|
||||
});
|
||||
}
|
||||
|
||||
// --- Skills ---
|
||||
|
||||
function loadSkills() {
|
||||
var skillsList = document.getElementById('skills-list');
|
||||
apiFetch('/api/skills').then(function(data) {
|
||||
if (!data.skills || data.skills.length === 0) {
|
||||
skillsList.innerHTML = '<div class="empty-state">No skills installed</div>';
|
||||
return;
|
||||
}
|
||||
skillsList.innerHTML = '';
|
||||
for (var i = 0; i < data.skills.length; i++) {
|
||||
skillsList.appendChild(renderSkillCard(data.skills[i]));
|
||||
}
|
||||
}).catch(function(err) {
|
||||
skillsList.innerHTML = '<div class="empty-state">Failed to load skills: ' + escapeHtml(err.message) + '</div>';
|
||||
});
|
||||
}
|
||||
|
||||
function renderSkillCard(skill) {
|
||||
var card = document.createElement('div');
|
||||
card.className = 'ext-card';
|
||||
|
||||
var header = document.createElement('div');
|
||||
header.className = 'ext-header';
|
||||
|
||||
var name = document.createElement('span');
|
||||
name.className = 'ext-name';
|
||||
name.textContent = skill.name;
|
||||
header.appendChild(name);
|
||||
|
||||
var trust = document.createElement('span');
|
||||
var trustClass = skill.trust.toLowerCase() === 'trusted' ? 'trust-trusted' : 'trust-installed';
|
||||
trust.className = 'skill-trust ' + trustClass;
|
||||
trust.textContent = skill.trust;
|
||||
header.appendChild(trust);
|
||||
|
||||
var version = document.createElement('span');
|
||||
version.className = 'skill-version';
|
||||
version.textContent = 'v' + skill.version;
|
||||
header.appendChild(version);
|
||||
|
||||
card.appendChild(header);
|
||||
|
||||
var desc = document.createElement('div');
|
||||
desc.className = 'ext-desc';
|
||||
desc.textContent = skill.description;
|
||||
card.appendChild(desc);
|
||||
|
||||
if (skill.keywords && skill.keywords.length > 0) {
|
||||
var kw = document.createElement('div');
|
||||
kw.className = 'ext-keywords';
|
||||
kw.textContent = 'Activates on: ' + skill.keywords.join(', ');
|
||||
card.appendChild(kw);
|
||||
}
|
||||
|
||||
var actions = document.createElement('div');
|
||||
actions.className = 'ext-actions';
|
||||
|
||||
// Only show Remove for registry-installed skills, not user-placed trusted skills
|
||||
if (skill.trust.toLowerCase() !== 'trusted') {
|
||||
var removeBtn = document.createElement('button');
|
||||
removeBtn.className = 'btn-ext remove';
|
||||
removeBtn.textContent = 'Remove';
|
||||
removeBtn.addEventListener('click', function() { removeSkill(skill.name); });
|
||||
actions.appendChild(removeBtn);
|
||||
}
|
||||
|
||||
card.appendChild(actions);
|
||||
return card;
|
||||
}
|
||||
|
||||
function searchClawHub() {
|
||||
var input = document.getElementById('skill-search-input');
|
||||
var query = input.value.trim();
|
||||
if (!query) return;
|
||||
|
||||
var resultsDiv = document.getElementById('skill-search-results');
|
||||
resultsDiv.innerHTML = '<div class="empty-state">Searching...</div>';
|
||||
|
||||
apiFetch('/api/skills/search', {
|
||||
method: 'POST',
|
||||
body: { query: query },
|
||||
}).then(function(data) {
|
||||
resultsDiv.innerHTML = '';
|
||||
|
||||
// Show registry error as a warning banner if present
|
||||
if (data.catalog_error) {
|
||||
var warning = document.createElement('div');
|
||||
warning.className = 'empty-state';
|
||||
warning.style.color = '#f0ad4e';
|
||||
warning.style.borderLeft = '3px solid #f0ad4e';
|
||||
warning.style.paddingLeft = '12px';
|
||||
warning.style.marginBottom = '16px';
|
||||
warning.textContent = 'Could not reach ClawHub registry: ' + data.catalog_error;
|
||||
resultsDiv.appendChild(warning);
|
||||
}
|
||||
|
||||
// Show catalog results
|
||||
if (data.catalog && data.catalog.length > 0) {
|
||||
// Build a set of installed skill names for quick lookup
|
||||
var installedNames = {};
|
||||
if (data.installed) {
|
||||
for (var j = 0; j < data.installed.length; j++) {
|
||||
installedNames[data.installed[j].name] = true;
|
||||
}
|
||||
}
|
||||
|
||||
for (var i = 0; i < data.catalog.length; i++) {
|
||||
var card = renderCatalogSkillCard(data.catalog[i], installedNames);
|
||||
card.style.animationDelay = (i * 0.06) + 's';
|
||||
resultsDiv.appendChild(card);
|
||||
}
|
||||
}
|
||||
|
||||
// Show matching installed skills too
|
||||
if (data.installed && data.installed.length > 0) {
|
||||
for (var k = 0; k < data.installed.length; k++) {
|
||||
var installedCard = renderSkillCard(data.installed[k]);
|
||||
installedCard.style.animationDelay = ((data.catalog ? data.catalog.length : 0) + k) * 0.06 + 's';
|
||||
installedCard.classList.add('skill-search-result');
|
||||
resultsDiv.appendChild(installedCard);
|
||||
}
|
||||
}
|
||||
|
||||
if (resultsDiv.children.length === 0) {
|
||||
resultsDiv.innerHTML = '<div class="empty-state">No skills found for "' + escapeHtml(query) + '"</div>';
|
||||
}
|
||||
}).catch(function(err) {
|
||||
resultsDiv.innerHTML = '<div class="empty-state">Search failed: ' + escapeHtml(err.message) + '</div>';
|
||||
});
|
||||
}
|
||||
|
||||
function renderCatalogSkillCard(entry, installedNames) {
|
||||
var card = document.createElement('div');
|
||||
card.className = 'ext-card ext-available skill-search-result';
|
||||
|
||||
var header = document.createElement('div');
|
||||
header.className = 'ext-header';
|
||||
|
||||
var name = document.createElement('a');
|
||||
name.className = 'ext-name';
|
||||
name.textContent = entry.name || entry.slug;
|
||||
name.href = 'https://clawhub.ai/skills/' + encodeURIComponent(entry.slug);
|
||||
name.target = '_blank';
|
||||
name.rel = 'noopener';
|
||||
name.style.textDecoration = 'none';
|
||||
name.style.color = 'inherit';
|
||||
name.title = 'View on ClawHub';
|
||||
header.appendChild(name);
|
||||
|
||||
if (entry.version) {
|
||||
var version = document.createElement('span');
|
||||
version.className = 'skill-version';
|
||||
version.textContent = 'v' + entry.version;
|
||||
header.appendChild(version);
|
||||
}
|
||||
|
||||
card.appendChild(header);
|
||||
|
||||
if (entry.description) {
|
||||
var desc = document.createElement('div');
|
||||
desc.className = 'ext-desc';
|
||||
desc.textContent = entry.description;
|
||||
card.appendChild(desc);
|
||||
}
|
||||
|
||||
// Metadata row: owner, stars, downloads, recency
|
||||
var meta = document.createElement('div');
|
||||
meta.className = 'ext-meta';
|
||||
meta.style.fontSize = '11px';
|
||||
meta.style.color = '#888';
|
||||
meta.style.marginTop = '6px';
|
||||
|
||||
function addMetaSep() {
|
||||
if (meta.children.length > 0) {
|
||||
meta.appendChild(document.createTextNode(' \u00b7 '));
|
||||
}
|
||||
}
|
||||
|
||||
if (entry.owner) {
|
||||
var ownerSpan = document.createElement('span');
|
||||
ownerSpan.textContent = 'by ' + entry.owner;
|
||||
meta.appendChild(ownerSpan);
|
||||
}
|
||||
|
||||
if (entry.stars != null) {
|
||||
addMetaSep();
|
||||
var starsSpan = document.createElement('span');
|
||||
starsSpan.textContent = entry.stars + ' stars';
|
||||
meta.appendChild(starsSpan);
|
||||
}
|
||||
|
||||
if (entry.downloads != null) {
|
||||
addMetaSep();
|
||||
var dlSpan = document.createElement('span');
|
||||
dlSpan.textContent = formatCompactNumber(entry.downloads) + ' downloads';
|
||||
meta.appendChild(dlSpan);
|
||||
}
|
||||
|
||||
if (entry.updatedAt) {
|
||||
var ago = formatTimeAgo(entry.updatedAt);
|
||||
if (ago) {
|
||||
addMetaSep();
|
||||
var updatedSpan = document.createElement('span');
|
||||
updatedSpan.textContent = 'updated ' + ago;
|
||||
meta.appendChild(updatedSpan);
|
||||
}
|
||||
}
|
||||
|
||||
if (meta.children.length > 0) {
|
||||
card.appendChild(meta);
|
||||
}
|
||||
|
||||
var actions = document.createElement('div');
|
||||
actions.className = 'ext-actions';
|
||||
|
||||
var slug = entry.slug || entry.name;
|
||||
var isInstalled = installedNames[entry.name] || installedNames[slug];
|
||||
|
||||
if (isInstalled) {
|
||||
var label = document.createElement('span');
|
||||
label.className = 'ext-active-label';
|
||||
label.textContent = 'Installed';
|
||||
actions.appendChild(label);
|
||||
} else {
|
||||
var installBtn = document.createElement('button');
|
||||
installBtn.className = 'btn-ext install';
|
||||
installBtn.textContent = 'Install';
|
||||
installBtn.addEventListener('click', (function(s, btn) {
|
||||
return function() {
|
||||
if (!confirm('Install skill "' + s + '" from ClawHub?')) return;
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Installing...';
|
||||
installSkill(s, null, btn);
|
||||
};
|
||||
})(slug, installBtn));
|
||||
actions.appendChild(installBtn);
|
||||
}
|
||||
|
||||
card.appendChild(actions);
|
||||
return card;
|
||||
}
|
||||
|
||||
function formatCompactNumber(n) {
|
||||
if (n >= 1000000) return (n / 1000000).toFixed(1) + 'M';
|
||||
if (n >= 1000) return (n / 1000).toFixed(1) + 'K';
|
||||
return '' + n;
|
||||
}
|
||||
|
||||
function formatTimeAgo(epochMs) {
|
||||
var now = Date.now();
|
||||
var diff = now - epochMs;
|
||||
if (diff < 0) return null;
|
||||
var minutes = Math.floor(diff / 60000);
|
||||
if (minutes < 60) return minutes <= 1 ? 'just now' : minutes + 'm ago';
|
||||
var hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return hours + 'h ago';
|
||||
var days = Math.floor(hours / 24);
|
||||
if (days < 30) return days + 'd ago';
|
||||
var months = Math.floor(days / 30);
|
||||
if (months < 12) return months + 'mo ago';
|
||||
return Math.floor(months / 12) + 'y ago';
|
||||
}
|
||||
|
||||
function installSkill(nameOrSlug, url, btn) {
|
||||
var body = { name: nameOrSlug };
|
||||
if (url) body.url = url;
|
||||
|
||||
apiFetch('/api/skills/install', {
|
||||
method: 'POST',
|
||||
headers: { 'X-Confirm-Action': 'true' },
|
||||
body: body,
|
||||
}).then(function(res) {
|
||||
if (res.success) {
|
||||
showToast('Installed skill "' + nameOrSlug + '"', 'success');
|
||||
} else {
|
||||
showToast('Install failed: ' + (res.message || 'unknown error'), 'error');
|
||||
}
|
||||
loadSkills();
|
||||
if (btn) { btn.disabled = false; btn.textContent = 'Install'; }
|
||||
}).catch(function(err) {
|
||||
showToast('Install failed: ' + err.message, 'error');
|
||||
if (btn) { btn.disabled = false; btn.textContent = 'Install'; }
|
||||
});
|
||||
}
|
||||
|
||||
function removeSkill(name) {
|
||||
if (!confirm('Remove skill "' + name + '"?')) return;
|
||||
apiFetch('/api/skills/' + encodeURIComponent(name), {
|
||||
method: 'DELETE',
|
||||
headers: { 'X-Confirm-Action': 'true' },
|
||||
}).then(function(res) {
|
||||
if (res.success) {
|
||||
showToast('Removed skill "' + name + '"', 'success');
|
||||
} else {
|
||||
showToast('Remove failed: ' + (res.message || 'unknown error'), 'error');
|
||||
}
|
||||
loadSkills();
|
||||
}).catch(function(err) {
|
||||
showToast('Remove failed: ' + err.message, 'error');
|
||||
});
|
||||
}
|
||||
|
||||
function installSkillFromForm() {
|
||||
var name = document.getElementById('skill-install-name').value.trim();
|
||||
if (!name) { showToast('Skill name is required', 'error'); return; }
|
||||
var url = document.getElementById('skill-install-url').value.trim() || null;
|
||||
if (url && !url.startsWith('https://')) {
|
||||
showToast('URL must use HTTPS', 'error');
|
||||
return;
|
||||
}
|
||||
if (!confirm('Install skill "' + name + '"?')) return;
|
||||
installSkill(name, url, null);
|
||||
document.getElementById('skill-install-name').value = '';
|
||||
document.getElementById('skill-install-url').value = '';
|
||||
}
|
||||
|
||||
// Wire up Enter key on search input
|
||||
document.getElementById('skill-search-input').addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Enter') searchClawHub();
|
||||
});
|
||||
|
||||
// --- Keyboard shortcuts ---
|
||||
|
||||
document.addEventListener('keydown', (e) => {
|
||||
@@ -2720,10 +3047,10 @@ document.addEventListener('keydown', (e) => {
|
||||
const tag = (e.target.tagName || '').toLowerCase();
|
||||
const inInput = tag === 'input' || tag === 'textarea';
|
||||
|
||||
// Mod+1-5: switch tabs
|
||||
if (mod && e.key >= '1' && e.key <= '5') {
|
||||
// Mod+1-6: switch tabs
|
||||
if (mod && e.key >= '1' && e.key <= '6') {
|
||||
e.preventDefault();
|
||||
const tabs = ['chat', 'memory', 'jobs', 'routines', 'extensions'];
|
||||
const tabs = ['chat', 'memory', 'jobs', 'routines', 'extensions', 'skills'];
|
||||
const idx = parseInt(e.key) - 1;
|
||||
if (tabs[idx]) switchTab(tabs[idx]);
|
||||
return;
|
||||
|
||||
@@ -42,6 +42,7 @@
|
||||
<button data-tab="jobs">Jobs</button>
|
||||
<button data-tab="routines">Routines</button>
|
||||
<button data-tab="extensions">Extensions</button>
|
||||
<button data-tab="skills">Skills</button>
|
||||
<div class="spacer"></div>
|
||||
<button class="status-logs-btn" data-tab="logs" title="Logs">Logs</button>
|
||||
<div class="tee-shield" id="tee-shield" style="display:none" title="Running in a Trusted Execution Environment">
|
||||
@@ -231,6 +232,34 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Skills Tab -->
|
||||
<div class="tab-panel" id="tab-skills">
|
||||
<div class="extensions-container">
|
||||
<div class="extensions-section">
|
||||
<h3>Search ClawHub</h3>
|
||||
<div class="skill-search-box">
|
||||
<input type="text" id="skill-search-input" placeholder="Search for skills...">
|
||||
<button onclick="searchClawHub()">Search</button>
|
||||
</div>
|
||||
<div class="extensions-list" id="skill-search-results"></div>
|
||||
</div>
|
||||
<div class="extensions-section">
|
||||
<h3>Installed Skills</h3>
|
||||
<div class="extensions-list" id="skills-list">
|
||||
<div class="empty-state">Loading skills...</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="extensions-section">
|
||||
<h3>Install Skill by URL</h3>
|
||||
<div class="ext-install-form">
|
||||
<input type="text" id="skill-install-name" placeholder="Skill name or slug">
|
||||
<input type="text" id="skill-install-url" placeholder="HTTPS URL to SKILL.md (optional)">
|
||||
<button onclick="installSkillFromForm()">Install</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="toasts"></div>
|
||||
|
||||
@@ -2809,6 +2809,82 @@ mark {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
/* --- Skills tab --- */
|
||||
|
||||
.skill-search-box {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.skill-search-box input {
|
||||
flex: 1;
|
||||
padding: 8px 12px;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.skill-search-box input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px rgba(52, 211, 153, 0.1);
|
||||
}
|
||||
|
||||
.skill-search-box button {
|
||||
padding: 8px 20px;
|
||||
background: var(--accent);
|
||||
color: #09090b;
|
||||
border: none;
|
||||
border-radius: var(--radius);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
transition: background 0.2s, transform 0.2s;
|
||||
}
|
||||
|
||||
.skill-search-box button:hover {
|
||||
background: var(--accent-hover);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.skill-trust {
|
||||
font-size: 10px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 8px;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
.skill-trust.trust-trusted {
|
||||
background: rgba(52, 211, 153, 0.15);
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.skill-trust.trust-installed {
|
||||
background: rgba(96, 165, 250, 0.15);
|
||||
color: #60a5fa;
|
||||
}
|
||||
|
||||
.skill-version {
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
@keyframes skillFadeIn {
|
||||
from { opacity: 0; transform: translateY(8px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.skill-search-result {
|
||||
animation: skillFadeIn 0.3s ease-out both;
|
||||
}
|
||||
|
||||
/* --- Activity toolbar --- */
|
||||
|
||||
.activity-toolbar {
|
||||
|
||||
@@ -508,6 +508,9 @@ pub struct SkillSearchResponse {
|
||||
pub catalog: Vec<serde_json::Value>,
|
||||
pub installed: Vec<SkillInfo>,
|
||||
pub registry_url: String,
|
||||
/// If the catalog registry was unreachable or errored, a human-readable message.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub catalog_error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
|
||||
Reference in New Issue
Block a user