mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-26 23:50:17 +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
@@ -68,6 +68,7 @@ pub struct AgentDeps {
|
||||
pub workspace: Option<Arc<Workspace>>,
|
||||
pub extension_manager: Option<Arc<ExtensionManager>>,
|
||||
pub skill_registry: Option<Arc<std::sync::RwLock<SkillRegistry>>>,
|
||||
pub skill_catalog: Option<Arc<crate::skills::catalog::SkillCatalog>>,
|
||||
pub skills_config: SkillsConfig,
|
||||
pub hooks: Arc<HookRegistry>,
|
||||
/// Cost enforcement guardrails (daily budget, hourly rate limits).
|
||||
@@ -174,6 +175,10 @@ impl Agent {
|
||||
self.deps.skill_registry.as_ref()
|
||||
}
|
||||
|
||||
pub(super) fn skill_catalog(&self) -> Option<&Arc<crate::skills::catalog::SkillCatalog>> {
|
||||
self.deps.skill_catalog.as_ref()
|
||||
}
|
||||
|
||||
/// Select active skills for a message using deterministic prefiltering.
|
||||
pub(super) fn select_active_skills(
|
||||
&self,
|
||||
|
||||
@@ -15,6 +15,17 @@ use crate::channels::{IncomingMessage, StatusUpdate};
|
||||
use crate::error::Error;
|
||||
use crate::llm::{ChatMessage, Reasoning};
|
||||
|
||||
/// Format a count with a suffix, using K/M abbreviations for large numbers.
|
||||
fn format_count(n: u64, suffix: &str) -> String {
|
||||
if n >= 1_000_000 {
|
||||
format!("{:.1}M {}", n as f64 / 1_000_000.0, suffix)
|
||||
} else if n >= 1_000 {
|
||||
format!("{:.1}K {}", n as f64 / 1_000.0, suffix)
|
||||
} else {
|
||||
format!("{} {}", n, suffix)
|
||||
}
|
||||
}
|
||||
|
||||
impl Agent {
|
||||
/// Handle job-related intents without turn tracking.
|
||||
pub(super) async fn handle_job_or_command(
|
||||
@@ -373,6 +384,10 @@ impl Agent {
|
||||
" /thread <id> Switch to thread\n",
|
||||
" /resume <id> Resume from checkpoint\n",
|
||||
"\n",
|
||||
"Skills:\n",
|
||||
" /skills List installed skills\n",
|
||||
" /skills search <q> Search ClawHub registry\n",
|
||||
"\n",
|
||||
"Agent:\n",
|
||||
" /heartbeat Run heartbeat check\n",
|
||||
" /summarize Summarize current thread\n",
|
||||
@@ -405,6 +420,22 @@ impl Agent {
|
||||
))
|
||||
}
|
||||
|
||||
"skills" => {
|
||||
if args.first().map(|s| s.as_str()) == Some("search") {
|
||||
let query = args[1..].join(" ");
|
||||
if query.is_empty() {
|
||||
return Ok(SubmissionResult::error("Usage: /skills search <query>"));
|
||||
}
|
||||
self.handle_skills_search(&query).await
|
||||
} else if args.is_empty() {
|
||||
self.handle_skills_list().await
|
||||
} else {
|
||||
Ok(SubmissionResult::error(
|
||||
"Usage: /skills or /skills search <query>",
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
"model" => {
|
||||
let current = self.llm().active_model_name();
|
||||
|
||||
@@ -475,6 +506,129 @@ impl Agent {
|
||||
}
|
||||
}
|
||||
|
||||
/// List installed skills.
|
||||
async fn handle_skills_list(&self) -> Result<SubmissionResult, Error> {
|
||||
let Some(registry) = self.skill_registry() else {
|
||||
return Ok(SubmissionResult::error("Skills system not enabled."));
|
||||
};
|
||||
|
||||
let guard = match registry.read() {
|
||||
Ok(g) => g,
|
||||
Err(e) => {
|
||||
return Ok(SubmissionResult::error(format!(
|
||||
"Skill registry lock error: {}",
|
||||
e
|
||||
)));
|
||||
}
|
||||
};
|
||||
|
||||
let skills = guard.skills();
|
||||
if skills.is_empty() {
|
||||
return Ok(SubmissionResult::response(
|
||||
"No skills installed.\n\nUse /skills search <query> to find skills on ClawHub.",
|
||||
));
|
||||
}
|
||||
|
||||
let mut out = String::from("Installed skills:\n\n");
|
||||
for s in skills {
|
||||
let desc = if s.manifest.description.chars().count() > 60 {
|
||||
let truncated: String = s.manifest.description.chars().take(57).collect();
|
||||
format!("{}...", truncated)
|
||||
} else {
|
||||
s.manifest.description.clone()
|
||||
};
|
||||
out.push_str(&format!(
|
||||
" {:<24} v{:<10} [{}] {}\n",
|
||||
s.manifest.name, s.manifest.version, s.trust, desc,
|
||||
));
|
||||
}
|
||||
out.push_str("\nUse /skills search <query> to find more on ClawHub.");
|
||||
|
||||
Ok(SubmissionResult::response(out))
|
||||
}
|
||||
|
||||
/// Search ClawHub for skills.
|
||||
async fn handle_skills_search(&self, query: &str) -> Result<SubmissionResult, Error> {
|
||||
let catalog = match self.skill_catalog() {
|
||||
Some(c) => c,
|
||||
None => {
|
||||
return Ok(SubmissionResult::error("Skill catalog not available."));
|
||||
}
|
||||
};
|
||||
|
||||
let outcome = catalog.search(query).await;
|
||||
|
||||
// Enrich top results with detail data (stars, downloads, owner)
|
||||
let mut entries = outcome.results;
|
||||
catalog.enrich_search_results(&mut entries, 5).await;
|
||||
|
||||
let mut out = format!("ClawHub results for \"{}\":\n\n", query);
|
||||
|
||||
if entries.is_empty() {
|
||||
if let Some(ref err) = outcome.error {
|
||||
out.push_str(&format!(" (registry error: {})\n", err));
|
||||
} else {
|
||||
out.push_str(" No results found.\n");
|
||||
}
|
||||
} else {
|
||||
for entry in &entries {
|
||||
let owner_str = entry
|
||||
.owner
|
||||
.as_deref()
|
||||
.map(|o| format!(" by {}", o))
|
||||
.unwrap_or_default();
|
||||
|
||||
let stats_parts: Vec<String> = [
|
||||
entry.stars.map(|s| format!("{} stars", s)),
|
||||
entry.downloads.map(|d| format_count(d, "downloads")),
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.collect();
|
||||
let stats_str = if stats_parts.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(" {}", stats_parts.join(" "))
|
||||
};
|
||||
|
||||
out.push_str(&format!(
|
||||
" {:<24} v{:<10}{}{}\n",
|
||||
entry.name, entry.version, owner_str, stats_str,
|
||||
));
|
||||
if !entry.description.is_empty() {
|
||||
out.push_str(&format!(" {}\n\n", entry.description));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Show matching installed skills
|
||||
if let Some(registry) = self.skill_registry()
|
||||
&& let Ok(guard) = registry.read()
|
||||
{
|
||||
let query_lower = query.to_lowercase();
|
||||
let matches: Vec<_> = guard
|
||||
.skills()
|
||||
.iter()
|
||||
.filter(|s| {
|
||||
s.manifest.name.to_lowercase().contains(&query_lower)
|
||||
|| s.manifest.description.to_lowercase().contains(&query_lower)
|
||||
})
|
||||
.collect();
|
||||
|
||||
if !matches.is_empty() {
|
||||
out.push_str(&format!("Installed skills matching \"{}\":\n", query));
|
||||
for s in &matches {
|
||||
out.push_str(&format!(
|
||||
" {:<24} v{:<10} [{}]\n",
|
||||
s.manifest.name, s.manifest.version, s.trust,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(SubmissionResult::response(out))
|
||||
}
|
||||
|
||||
/// Handle legacy command routing from the Router (job commands that go through
|
||||
/// process_user_input -> router -> handle_job_or_command -> here).
|
||||
pub(super) async fn handle_command(
|
||||
|
||||
@@ -960,6 +960,7 @@ mod tests {
|
||||
workspace: None,
|
||||
extension_manager: None,
|
||||
skill_registry: None,
|
||||
skill_catalog: None,
|
||||
skills_config: SkillsConfig::default(),
|
||||
hooks: Arc::new(HookRegistry::new()),
|
||||
cost_guard: Arc::new(CostGuard::new(CostGuardConfig::default())),
|
||||
|
||||
@@ -62,6 +62,23 @@ impl SubmissionParser {
|
||||
args: vec![],
|
||||
};
|
||||
}
|
||||
if lower == "/skills" {
|
||||
return Submission::SystemCommand {
|
||||
command: "skills".to_string(),
|
||||
args: vec![],
|
||||
};
|
||||
}
|
||||
if lower.starts_with("/skills ") {
|
||||
let args: Vec<String> = trimmed
|
||||
.split_whitespace()
|
||||
.skip(1)
|
||||
.map(|s| s.to_string())
|
||||
.collect();
|
||||
return Submission::SystemCommand {
|
||||
command: "skills".to_string(),
|
||||
args,
|
||||
};
|
||||
}
|
||||
if lower == "/ping" {
|
||||
return Submission::SystemCommand {
|
||||
command: "ping".to_string(),
|
||||
@@ -693,6 +710,36 @@ mod tests {
|
||||
assert!(!submission.starts_turn());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parser_system_command_skills() {
|
||||
let submission = SubmissionParser::parse("/skills");
|
||||
assert!(
|
||||
matches!(submission, Submission::SystemCommand { command, args } if command == "skills" && args.is_empty())
|
||||
);
|
||||
|
||||
// Case insensitive
|
||||
let submission = SubmissionParser::parse("/SKILLS");
|
||||
assert!(
|
||||
matches!(submission, Submission::SystemCommand { command, .. } if command == "skills")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parser_system_command_skills_search() {
|
||||
let submission = SubmissionParser::parse("/skills search markdown");
|
||||
assert!(
|
||||
matches!(submission, Submission::SystemCommand { command, args }
|
||||
if command == "skills" && args == vec!["search", "markdown"])
|
||||
);
|
||||
|
||||
// Multiple words in query
|
||||
let submission = SubmissionParser::parse("/skills search code review tools");
|
||||
assert!(
|
||||
matches!(submission, Submission::SystemCommand { command, args }
|
||||
if command == "skills" && args == vec!["search", "code", "review", "tools"])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parser_quit() {
|
||||
assert!(matches!(SubmissionParser::parse("/quit"), Submission::Quit));
|
||||
|
||||
Reference in New Issue
Block a user