use anyhow::{Result, anyhow};
use serde_json::{Value, json};
use super::runtime::AgentRuntime;
use rsclaw_skill::SkillManifest;
pub(crate) fn paginate_skill_list<'a, I>(skills: I, args: &Value) -> Value
where
I: IntoIterator<Item = &'a SkillManifest>,
{
let query = args["query"]
.as_str()
.map(str::trim)
.filter(|s| !s.is_empty());
let query_lower = query.map(|q| q.to_lowercase());
let limit = args["limit"]
.as_u64()
.and_then(|n| usize::try_from(n).ok())
.unwrap_or(50)
.clamp(1, 100);
let offset = args["offset"]
.as_u64()
.and_then(|n| usize::try_from(n).ok())
.unwrap_or(0);
let mut all = skills.into_iter().collect::<Vec<_>>();
let total = all.len();
all.retain(|s| {
let Some(query) = query_lower.as_deref() else {
return true;
};
s.name.to_lowercase().contains(query)
|| s.description
.as_deref()
.unwrap_or_default()
.to_lowercase()
.contains(query)
});
all.sort_by(|a, b| a.name.cmp(&b.name));
let matched = all.len();
let skills = all
.into_iter()
.skip(offset)
.take(limit)
.map(|s| {
json!({
"name": s.name,
"description": s.description.clone().unwrap_or_default(),
})
})
.collect::<Vec<_>>();
let next_offset = offset + skills.len();
let has_more = next_offset < matched;
json!({
"count": total,
"matched": matched,
"offset": offset,
"limit": limit,
"has_more": has_more,
"next_offset": has_more.then_some(next_offset),
"skills": skills,
})
}
impl AgentRuntime {
pub(crate) fn tool_use_skill(&self, args: Value) -> Result<Value> {
let name = args
.get("name")
.or_else(|| args.get("skill"))
.or_else(|| args.get("skill_name"))
.and_then(|v| v.as_str())
.ok_or_else(|| {
let available: Vec<String> = self.skills.all().map(|s| s.name.clone()).collect();
anyhow!(
"use_skill: 'name' is required. Available skills: {}. \
Received args: {}",
available.join(", "),
args
)
})?;
let skill_dir = if let Some(skill) = self.skills.get(name) {
skill.dir.clone()
} else if !rsclaw_skill::valid_slug(name) {
return Ok(serde_json::json!({ "error": format!("invalid skill name: {name:?}") }));
} else {
let disk = rsclaw_config::loader::base_dir().join("skills").join(name);
if disk.join("SKILL.md").is_file() {
disk
} else {
let available: Vec<&str> = self.skills.all().map(|s| s.name.as_str()).collect();
return Ok(serde_json::json!({
"error": format!("skill '{name}' not installed"),
"available": available,
}));
}
};
let dir = skill_dir.display().to_string();
let skill_md_path = skill_dir.join("SKILL.md");
let skill_md = std::fs::read_to_string(&skill_md_path).unwrap_or_else(|e| {
format!(
"(failed to read SKILL.md: {e}; check {})",
skill_md_path.display()
)
});
if let Err(e) = rsclaw_skill::record_skill_use(&self.store.db, name) {
tracing::debug!(skill = name, "skill use stat write failed: {e:#}");
}
Ok(serde_json::json!({
"name": name,
"dir": dir,
"skill_md": skill_md,
"next_step": "Read skill_md to find the exact CLI command and flags, \
then call shell to run it. \
Pass the user's actual question / parameters via the \
flags documented in skill_md."
}))
}
pub(crate) fn tool_skill_list(&self, args: Value) -> Result<Value> {
Ok(paginate_skill_list(self.skills.all(), &args))
}
pub(crate) async fn tool_skill_search(&self, args: Value) -> Result<Value> {
let query = args["query"]
.as_str()
.or_else(|| args["q"].as_str())
.unwrap_or("")
.trim()
.to_owned();
if query.is_empty() {
return Ok(json!({ "error": "query is required" }));
}
let client = rsclaw_skill::clawhub::ClawhubClient::new();
let results = match client.search_with_fallback(&query).await {
Ok(r) => r,
Err(e) => return Ok(json!({ "error": format!("skill search failed: {e}") })),
};
let out: Vec<Value> = results
.into_iter()
.take(20)
.map(|r| {
json!({
"slug": r.slug,
"description": r.description,
"installs": r.installs,
"registry": r.registry,
})
})
.collect();
Ok(json!({
"count": out.len(),
"results": out,
"hint": "To use one: skill_install(name=\"<slug>\") then skill_use(name=\"<slug>\").",
}))
}
pub(crate) async fn tool_skill_install(&self, args: Value) -> Result<Value> {
let name = args["name"]
.as_str()
.or_else(|| args["slug"].as_str())
.unwrap_or("")
.trim()
.to_owned();
if name.is_empty() {
return Ok(json!({ "error": "name is required" }));
}
if !rsclaw_skill::valid_slug(&name) {
return Ok(json!({ "error": format!("invalid skill name: {name:?}") }));
}
let Some(entry) = rsclaw_skill::allowlist::snapshot().lookup_skill(&name) else {
let confirmed = args["confirmed"].as_bool().unwrap_or(false);
if !confirmed {
return Ok(json!({
"error": format!("'{name}' is not on the audited auto-install allowlist"),
"needs_confirmation": true,
"guidance": format!(
"'{name}' is not pre-audited. Ask the user explicitly whether they trust \
and want to install it. ONLY if they clearly say yes, call skill_install \
again with confirmed=true. Do not set confirmed=true on your own."
),
}));
}
let dir = rsclaw_config::loader::base_dir().join("skills");
let client = rsclaw_skill::clawhub::ClawhubClient::new();
return match client.install_with_fallback(&name, &dir).await {
Ok(locked) => Ok(json!({
"installed": name,
"version": locked.version,
"dir": locked.install_dir.display().to_string(),
"off_allowlist": true,
"note": "Installed off-allowlist with user confirmation (not content-pinned).",
"next_step": "Usable now: skill_use(name=\"...\") to read its SKILL.md, then run the CLI it documents via shell.",
})),
Err(e) => Ok(json!({ "error": format!("install failed: {e}"), "name": name })),
};
};
if entry.url.is_empty() {
return Ok(json!({ "error": format!("allowlist entry '{name}' has no download url") }));
}
let dir = rsclaw_config::loader::base_dir().join("skills");
let client = rsclaw_skill::clawhub::ClawhubClient::new();
match client.install_with_fallback(&entry.url, &dir).await {
Ok(locked) => {
if let Err(e) =
rsclaw_skill::allowlist::verify_skill_content(&locked.install_dir, &entry, true)
{
let _ = std::fs::remove_dir_all(&locked.install_dir);
return Ok(json!({ "error": e.to_string(), "name": name }));
}
Ok(json!({
"installed": name,
"version": locked.version,
"dir": locked.install_dir.display().to_string(),
"next_step": "Usable now: skill_use(name=\"...\") to read its SKILL.md, then run the CLI it documents via shell.",
}))
}
Err(e) => Ok(json!({ "error": format!("install failed: {e}"), "name": name })),
}
}
pub(crate) async fn tool_skill_remove(&self, args: Value) -> Result<Value> {
let name = args["name"]
.as_str()
.or_else(|| args["slug"].as_str())
.unwrap_or("")
.trim()
.to_owned();
if name.is_empty() {
return Ok(json!({ "error": "name is required" }));
}
if !rsclaw_skill::valid_slug(&name) {
return Ok(json!({ "error": format!("invalid skill name: {name:?}") }));
}
let dir = rsclaw_config::loader::base_dir().join("skills").join(&name);
if !dir.is_dir() {
return Ok(json!({ "error": format!("skill '{name}' not installed") }));
}
match std::fs::remove_dir_all(&dir) {
Ok(_) => Ok(json!({
"removed": name,
"note": "Removed from disk. Drops from the system-prompt skill list on the next compaction/clear/new reload.",
})),
Err(e) => Ok(json!({ "error": format!("remove failed: {e}") })),
}
}
}