mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-25 14:53:34 +00:00
* 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]>
649 lines
24 KiB
Rust
649 lines
24 KiB
Rust
//! System commands and job handlers for the agent.
|
|
//!
|
|
//! Extracted from `agent_loop.rs` to isolate the /help, /model, /status,
|
|
//! and other command processing from the core agent loop.
|
|
|
|
use std::sync::Arc;
|
|
|
|
use tokio::sync::Mutex;
|
|
use uuid::Uuid;
|
|
|
|
use crate::agent::session::Session;
|
|
use crate::agent::submission::SubmissionResult;
|
|
use crate::agent::{Agent, MessageIntent};
|
|
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(
|
|
&self,
|
|
intent: MessageIntent,
|
|
message: &IncomingMessage,
|
|
) -> Result<SubmissionResult, Error> {
|
|
// Send thinking status for non-trivial operations
|
|
if let MessageIntent::CreateJob { .. } = &intent {
|
|
let _ = self
|
|
.channels
|
|
.send_status(
|
|
&message.channel,
|
|
StatusUpdate::Thinking("Processing...".into()),
|
|
&message.metadata,
|
|
)
|
|
.await;
|
|
}
|
|
|
|
let response = match intent {
|
|
MessageIntent::CreateJob {
|
|
title,
|
|
description,
|
|
category,
|
|
} => {
|
|
self.handle_create_job(&message.user_id, title, description, category)
|
|
.await?
|
|
}
|
|
MessageIntent::CheckJobStatus { job_id } => {
|
|
self.handle_check_status(&message.user_id, job_id).await?
|
|
}
|
|
MessageIntent::CancelJob { job_id } => {
|
|
self.handle_cancel_job(&message.user_id, &job_id).await?
|
|
}
|
|
MessageIntent::ListJobs { filter } => {
|
|
self.handle_list_jobs(&message.user_id, filter).await?
|
|
}
|
|
MessageIntent::HelpJob { job_id } => {
|
|
self.handle_help_job(&message.user_id, &job_id).await?
|
|
}
|
|
MessageIntent::Command { command, args } => {
|
|
match self.handle_command(&command, &args).await? {
|
|
Some(s) => s,
|
|
None => return Ok(SubmissionResult::Ok { message: None }), // Shutdown signal
|
|
}
|
|
}
|
|
_ => "Unknown intent".to_string(),
|
|
};
|
|
Ok(SubmissionResult::response(response))
|
|
}
|
|
|
|
async fn handle_create_job(
|
|
&self,
|
|
user_id: &str,
|
|
title: String,
|
|
description: String,
|
|
category: Option<String>,
|
|
) -> Result<String, Error> {
|
|
let job_id = self
|
|
.scheduler
|
|
.dispatch_job(user_id, &title, &description, None)
|
|
.await?;
|
|
|
|
// Set the dedicated category field (not stored in metadata)
|
|
if let Some(cat) = category
|
|
&& let Err(e) = self
|
|
.context_manager
|
|
.update_context(job_id, |ctx| {
|
|
ctx.category = Some(cat);
|
|
})
|
|
.await
|
|
{
|
|
tracing::warn!(job_id = %job_id, "Failed to set job category: {}", e);
|
|
}
|
|
|
|
Ok(format!(
|
|
"Created job: {}\nID: {}\n\nThe job has been scheduled and is now running.",
|
|
title, job_id
|
|
))
|
|
}
|
|
|
|
async fn handle_check_status(
|
|
&self,
|
|
user_id: &str,
|
|
job_id: Option<String>,
|
|
) -> Result<String, Error> {
|
|
match job_id {
|
|
Some(id) => {
|
|
let uuid = Uuid::parse_str(&id)
|
|
.map_err(|_| crate::error::JobError::NotFound { id: Uuid::nil() })?;
|
|
|
|
let ctx = self.context_manager.get_context(uuid).await?;
|
|
if ctx.user_id != user_id {
|
|
return Err(crate::error::JobError::NotFound { id: uuid }.into());
|
|
}
|
|
|
|
Ok(format!(
|
|
"Job: {}\nStatus: {:?}\nCreated: {}\nStarted: {}\nActual cost: {}",
|
|
ctx.title,
|
|
ctx.state,
|
|
ctx.created_at.format("%Y-%m-%d %H:%M:%S"),
|
|
ctx.started_at
|
|
.map(|t| t.format("%Y-%m-%d %H:%M:%S").to_string())
|
|
.unwrap_or_else(|| "Not started".to_string()),
|
|
ctx.actual_cost
|
|
))
|
|
}
|
|
None => {
|
|
// Show summary of all jobs
|
|
let summary = self.context_manager.summary_for(user_id).await;
|
|
Ok(format!(
|
|
"Jobs summary:\n Total: {}\n In Progress: {}\n Completed: {}\n Failed: {}\n Stuck: {}",
|
|
summary.total,
|
|
summary.in_progress,
|
|
summary.completed,
|
|
summary.failed,
|
|
summary.stuck
|
|
))
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn handle_cancel_job(&self, user_id: &str, job_id: &str) -> Result<String, Error> {
|
|
let uuid = Uuid::parse_str(job_id)
|
|
.map_err(|_| crate::error::JobError::NotFound { id: Uuid::nil() })?;
|
|
|
|
let ctx = self.context_manager.get_context(uuid).await?;
|
|
if ctx.user_id != user_id {
|
|
return Err(crate::error::JobError::NotFound { id: uuid }.into());
|
|
}
|
|
|
|
self.scheduler.stop(uuid).await?;
|
|
|
|
Ok(format!("Job {} has been cancelled.", job_id))
|
|
}
|
|
|
|
async fn handle_list_jobs(
|
|
&self,
|
|
user_id: &str,
|
|
_filter: Option<String>,
|
|
) -> Result<String, Error> {
|
|
let jobs = self.context_manager.all_jobs_for(user_id).await;
|
|
|
|
if jobs.is_empty() {
|
|
return Ok("No jobs found.".to_string());
|
|
}
|
|
|
|
let mut output = String::from("Jobs:\n");
|
|
for job_id in jobs {
|
|
if let Ok(ctx) = self.context_manager.get_context(job_id).await
|
|
&& ctx.user_id == user_id
|
|
{
|
|
output.push_str(&format!(" {} - {} ({:?})\n", job_id, ctx.title, ctx.state));
|
|
}
|
|
}
|
|
|
|
Ok(output)
|
|
}
|
|
|
|
async fn handle_help_job(&self, user_id: &str, job_id: &str) -> Result<String, Error> {
|
|
let uuid = Uuid::parse_str(job_id)
|
|
.map_err(|_| crate::error::JobError::NotFound { id: Uuid::nil() })?;
|
|
|
|
let ctx = self.context_manager.get_context(uuid).await?;
|
|
if ctx.user_id != user_id {
|
|
return Err(crate::error::JobError::NotFound { id: uuid }.into());
|
|
}
|
|
|
|
if ctx.state == crate::context::JobState::Stuck {
|
|
// Attempt recovery
|
|
self.context_manager
|
|
.update_context(uuid, |ctx| ctx.attempt_recovery())
|
|
.await?
|
|
.map_err(|s| crate::error::JobError::ContextError {
|
|
id: uuid,
|
|
reason: s,
|
|
})?;
|
|
|
|
// Reschedule
|
|
self.scheduler.schedule(uuid).await?;
|
|
|
|
Ok(format!(
|
|
"Job {} was stuck. Attempting recovery (attempt #{}).",
|
|
job_id,
|
|
ctx.repair_attempts + 1
|
|
))
|
|
} else {
|
|
Ok(format!(
|
|
"Job {} is not stuck (current state: {:?}). No help needed.",
|
|
job_id, ctx.state
|
|
))
|
|
}
|
|
}
|
|
|
|
/// Trigger a manual heartbeat check.
|
|
pub(super) async fn process_heartbeat(&self) -> Result<SubmissionResult, Error> {
|
|
let Some(workspace) = self.workspace() else {
|
|
return Ok(SubmissionResult::error(
|
|
"Heartbeat requires a workspace (database must be connected).",
|
|
));
|
|
};
|
|
|
|
let runner = crate::agent::HeartbeatRunner::new(
|
|
crate::agent::HeartbeatConfig::default(),
|
|
crate::workspace::hygiene::HygieneConfig::default(),
|
|
workspace.clone(),
|
|
self.llm().clone(),
|
|
self.safety().clone(),
|
|
);
|
|
|
|
match runner.check_heartbeat().await {
|
|
crate::agent::HeartbeatResult::Ok => Ok(SubmissionResult::ok_with_message(
|
|
"Heartbeat: all clear, nothing needs attention.",
|
|
)),
|
|
crate::agent::HeartbeatResult::NeedsAttention(msg) => Ok(SubmissionResult::response(
|
|
format!("Heartbeat findings:\n\n{}", msg),
|
|
)),
|
|
crate::agent::HeartbeatResult::Skipped => Ok(SubmissionResult::ok_with_message(
|
|
"Heartbeat skipped: no HEARTBEAT.md checklist found in workspace.",
|
|
)),
|
|
crate::agent::HeartbeatResult::Failed(err) => Ok(SubmissionResult::error(format!(
|
|
"Heartbeat failed: {}",
|
|
err
|
|
))),
|
|
}
|
|
}
|
|
|
|
/// Summarize the current thread's conversation.
|
|
pub(super) async fn process_summarize(
|
|
&self,
|
|
session: Arc<Mutex<Session>>,
|
|
thread_id: Uuid,
|
|
) -> Result<SubmissionResult, Error> {
|
|
let messages = {
|
|
let sess = session.lock().await;
|
|
let thread = sess
|
|
.threads
|
|
.get(&thread_id)
|
|
.ok_or_else(|| Error::from(crate::error::JobError::NotFound { id: thread_id }))?;
|
|
thread.messages()
|
|
};
|
|
|
|
if messages.is_empty() {
|
|
return Ok(SubmissionResult::ok_with_message(
|
|
"Nothing to summarize (empty thread).",
|
|
));
|
|
}
|
|
|
|
// Build a summary prompt with the conversation
|
|
let mut context = Vec::new();
|
|
context.push(ChatMessage::system(
|
|
"Summarize the conversation so far in 3-5 concise bullet points. \
|
|
Focus on decisions made, actions taken, and key outcomes. \
|
|
Be brief and factual.",
|
|
));
|
|
// Include the conversation messages (truncate to last 20 to avoid context overflow)
|
|
let start = if messages.len() > 20 {
|
|
messages.len() - 20
|
|
} else {
|
|
0
|
|
};
|
|
context.extend_from_slice(&messages[start..]);
|
|
context.push(ChatMessage::user("Summarize this conversation."));
|
|
|
|
let request = crate::llm::CompletionRequest::new(context)
|
|
.with_max_tokens(512)
|
|
.with_temperature(0.3);
|
|
|
|
let reasoning = Reasoning::new(self.llm().clone(), self.safety().clone());
|
|
match reasoning.complete(request).await {
|
|
Ok((text, _usage)) => Ok(SubmissionResult::response(format!(
|
|
"Thread Summary:\n\n{}",
|
|
text.trim()
|
|
))),
|
|
Err(e) => Ok(SubmissionResult::error(format!("Summarize failed: {}", e))),
|
|
}
|
|
}
|
|
|
|
/// Suggest next steps based on the current thread.
|
|
pub(super) async fn process_suggest(
|
|
&self,
|
|
session: Arc<Mutex<Session>>,
|
|
thread_id: Uuid,
|
|
) -> Result<SubmissionResult, Error> {
|
|
let messages = {
|
|
let sess = session.lock().await;
|
|
let thread = sess
|
|
.threads
|
|
.get(&thread_id)
|
|
.ok_or_else(|| Error::from(crate::error::JobError::NotFound { id: thread_id }))?;
|
|
thread.messages()
|
|
};
|
|
|
|
if messages.is_empty() {
|
|
return Ok(SubmissionResult::ok_with_message(
|
|
"Nothing to suggest from (empty thread).",
|
|
));
|
|
}
|
|
|
|
let mut context = Vec::new();
|
|
context.push(ChatMessage::system(
|
|
"Based on the conversation so far, suggest 2-4 concrete next steps the user could take. \
|
|
Be actionable and specific. Format as a numbered list.",
|
|
));
|
|
let start = if messages.len() > 20 {
|
|
messages.len() - 20
|
|
} else {
|
|
0
|
|
};
|
|
context.extend_from_slice(&messages[start..]);
|
|
context.push(ChatMessage::user("What should I do next?"));
|
|
|
|
let request = crate::llm::CompletionRequest::new(context)
|
|
.with_max_tokens(512)
|
|
.with_temperature(0.5);
|
|
|
|
let reasoning = Reasoning::new(self.llm().clone(), self.safety().clone());
|
|
match reasoning.complete(request).await {
|
|
Ok((text, _usage)) => Ok(SubmissionResult::response(format!(
|
|
"Suggested Next Steps:\n\n{}",
|
|
text.trim()
|
|
))),
|
|
Err(e) => Ok(SubmissionResult::error(format!("Suggest failed: {}", e))),
|
|
}
|
|
}
|
|
|
|
/// Handle system commands that bypass thread-state checks entirely.
|
|
pub(super) async fn handle_system_command(
|
|
&self,
|
|
command: &str,
|
|
args: &[String],
|
|
) -> Result<SubmissionResult, Error> {
|
|
match command {
|
|
"help" => Ok(SubmissionResult::response(concat!(
|
|
"System:\n",
|
|
" /help Show this help\n",
|
|
" /model [name] Show or switch the active model\n",
|
|
" /version Show version info\n",
|
|
" /tools List available tools\n",
|
|
" /debug Toggle debug mode\n",
|
|
" /ping Connectivity check\n",
|
|
"\n",
|
|
"Jobs:\n",
|
|
" /job <desc> Create a new job\n",
|
|
" /status [id] Check job status\n",
|
|
" /cancel <id> Cancel a job\n",
|
|
" /list List all jobs\n",
|
|
"\n",
|
|
"Session:\n",
|
|
" /undo Undo last turn\n",
|
|
" /redo Redo undone turn\n",
|
|
" /compact Compress context window\n",
|
|
" /clear Clear current thread\n",
|
|
" /interrupt Stop current operation\n",
|
|
" /new New conversation thread\n",
|
|
" /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",
|
|
" /suggest Suggest next steps\n",
|
|
"\n",
|
|
" /quit Exit",
|
|
))),
|
|
|
|
"ping" => Ok(SubmissionResult::response("pong!")),
|
|
|
|
"version" => Ok(SubmissionResult::response(format!(
|
|
"{} v{}",
|
|
env!("CARGO_PKG_NAME"),
|
|
env!("CARGO_PKG_VERSION")
|
|
))),
|
|
|
|
"tools" => {
|
|
let tools = self.tools().list().await;
|
|
Ok(SubmissionResult::response(format!(
|
|
"Available tools: {}",
|
|
tools.join(", ")
|
|
)))
|
|
}
|
|
|
|
"debug" => {
|
|
// Debug toggle is handled client-side in the REPL.
|
|
// For non-REPL channels, just acknowledge.
|
|
Ok(SubmissionResult::ok_with_message(
|
|
"Debug toggle is handled by your client.",
|
|
))
|
|
}
|
|
|
|
"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();
|
|
|
|
if args.is_empty() {
|
|
// Show current model and list available models
|
|
let mut out = format!("Active model: {}\n", current);
|
|
match self.llm().list_models().await {
|
|
Ok(models) if !models.is_empty() => {
|
|
out.push_str("\nAvailable models:\n");
|
|
for m in &models {
|
|
let marker = if *m == current { " (active)" } else { "" };
|
|
out.push_str(&format!(" {}{}\n", m, marker));
|
|
}
|
|
out.push_str("\nUse /model <name> to switch.");
|
|
}
|
|
Ok(_) => {
|
|
out.push_str(
|
|
"\nCould not fetch model list. Use /model <name> to switch.",
|
|
);
|
|
}
|
|
Err(e) => {
|
|
out.push_str(&format!(
|
|
"\nCould not fetch models: {}. Use /model <name> to switch.",
|
|
e
|
|
));
|
|
}
|
|
}
|
|
Ok(SubmissionResult::response(out))
|
|
} else {
|
|
let requested = &args[0];
|
|
|
|
// Validate the model exists
|
|
match self.llm().list_models().await {
|
|
Ok(models) if !models.is_empty() => {
|
|
if !models.iter().any(|m| m == requested) {
|
|
return Ok(SubmissionResult::error(format!(
|
|
"Unknown model: {}. Available models:\n {}",
|
|
requested,
|
|
models.join("\n ")
|
|
)));
|
|
}
|
|
}
|
|
Ok(_) => {
|
|
// Empty model list, can't validate but try anyway
|
|
}
|
|
Err(e) => {
|
|
tracing::warn!("Could not fetch model list for validation: {}", e);
|
|
}
|
|
}
|
|
|
|
match self.llm().set_model(requested) {
|
|
Ok(()) => Ok(SubmissionResult::response(format!(
|
|
"Switched model to: {}",
|
|
requested
|
|
))),
|
|
Err(e) => Ok(SubmissionResult::error(format!(
|
|
"Failed to switch model: {}",
|
|
e
|
|
))),
|
|
}
|
|
}
|
|
}
|
|
|
|
_ => Ok(SubmissionResult::error(format!(
|
|
"Unknown command: {}. Try /help",
|
|
command
|
|
))),
|
|
}
|
|
}
|
|
|
|
/// 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(
|
|
&self,
|
|
command: &str,
|
|
args: &[String],
|
|
) -> Result<Option<String>, Error> {
|
|
// System commands are now handled directly via Submission::SystemCommand,
|
|
// but the router may still send us unknown /commands.
|
|
match self.handle_system_command(command, args).await? {
|
|
SubmissionResult::Response { content } => Ok(Some(content)),
|
|
SubmissionResult::Ok { message } => Ok(message),
|
|
SubmissionResult::Error { message } => Ok(Some(format!("Error: {}", message))),
|
|
_ => Ok(None),
|
|
}
|
|
}
|
|
}
|