use async_trait::async_trait;
use serde_json::{json, Value};
use std::sync::Arc;
use super::Tool;
use crate::skills::SkillRegistry;
async fn load_registry() -> Arc<SkillRegistry> {
crate::registries::skills().await
}
pub struct RunSkillTool;
#[async_trait]
impl Tool for RunSkillTool {
fn name(&self) -> &str {
"run_skill"
}
fn capability(&self) -> crate::risk::Capability {
crate::risk::Capability::Delegating
}
fn description(&self) -> &str {
"Load a skill by name and return its content as instructions. \
Use this when the user asks to activate a specific persona or skill \
(e.g. 'talk to Elliot', 'use the soroban skill')."
}
fn input_schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "Skill name to activate (e.g. 'elliot-dev', 'soroban')"
},
"context": {
"type": "string",
"description": "Optional user message or context to pass with the skill"
}
},
"required": ["name"]
})
}
async fn execute(&self, input: Value) -> Result<String, String> {
let name = input
.get("name")
.and_then(|v| v.as_str())
.ok_or_else(|| "Missing 'name' field".to_string())?;
let context = input.get("context").and_then(|v| v.as_str()).unwrap_or("");
let registry = load_registry().await;
let skill = registry.get(name).ok_or_else(|| {
let suggestions: Vec<_> = registry
.search(name)
.iter()
.map(|s| s.name.as_str())
.collect();
if suggestions.is_empty() {
format!(
"Skill '{}' not found. Use list_skills to see available skills.",
name
)
} else {
format!(
"Skill '{}' not found. Did you mean: {}?",
name,
suggestions.join(", ")
)
}
})?;
let mut output = format!(
"# Skill: {}\n## Source: {} — {}\n## Description: {}\n\n",
skill.name,
skill.provenance().cite(),
skill.path.display(),
skill.description
);
output.push_str(&skill.body);
if !context.is_empty() {
output.push_str(&format!("\n\n---\n\n## User Context\n\n{}", context));
}
Ok(output)
}
}
pub struct ListSkillsTool;
#[async_trait]
impl Tool for ListSkillsTool {
fn name(&self) -> &str {
"list_skills"
}
fn capability(&self) -> crate::risk::Capability {
crate::risk::Capability::ReadOnly
}
fn description(&self) -> &str {
"List all available skills (personas, knowledge modules, workflows). \
Shows name, description, and whether a customize.toml exists."
}
fn input_schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"search": {
"type": "string",
"description": "Optional search query to filter skills"
}
},
"required": []
})
}
async fn execute(&self, input: Value) -> Result<String, String> {
let search = input.get("search").and_then(|v| v.as_str()).unwrap_or("");
let registry = load_registry().await;
let skills = if search.is_empty() {
registry.all().to_vec()
} else {
registry.search(search).into_iter().cloned().collect()
};
if skills.is_empty() {
if search.is_empty() {
return Ok("No skills found.\n\nSkills are loaded from:\n\
- ~/.claude/skills/<name>/SKILL.md (global)\n\
- ~/.config/procyon/skills/<name>/SKILL.md (config)\n\
- .procyon/skills/<name>/SKILL.md (project)\n\
- .stellar-build/skills/<name>/SKILL.md (project)"
.to_string());
}
return Ok(format!("No skills match '{}'.", search));
}
let mut output = format!("Available skills ({}):\n\n", skills.len());
for skill in &skills {
let customize = if skill.has_customize {
" [customizable]"
} else {
""
};
output.push_str(&format!(
" {}{}\n {}\n {}\n\n",
skill.name,
customize,
truncate(&skill.description, 120),
skill.provenance().cite()
));
}
Ok(output)
}
}
fn truncate(s: &str, max_len: usize) -> String {
if s.len() <= max_len {
s.to_string()
} else {
format!("{}...", &s[..max_len - 3])
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tools::ToolRegistry;
#[tokio::test]
async fn list_skills_returns_message_when_empty() {
let tool = ListSkillsTool;
let result = tool.execute(json!({})).await.unwrap();
assert!(
result.contains("No skills found") || result.contains("Available skills"),
"got: {}",
result
);
}
#[tokio::test]
async fn run_skill_returns_error_for_unknown() {
let tool = RunSkillTool;
let err = tool
.execute(json!({"name": "nonexistent-skill-xyz"}))
.await
.unwrap_err();
assert!(err.contains("not found"), "got: {}", err);
}
#[tokio::test]
async fn list_skills_tool_is_callable() {
let tool = ListSkillsTool;
assert_eq!(tool.name(), "list_skills");
assert!(!tool.description().is_empty());
}
#[tokio::test]
async fn run_skill_tool_is_callable() {
let tool = RunSkillTool;
assert_eq!(tool.name(), "run_skill");
assert!(!tool.description().is_empty());
}
#[tokio::test]
async fn tools_register_in_registry() {
let mut registry = ToolRegistry::new();
registry.register(Box::new(RunSkillTool));
registry.register(Box::new(ListSkillsTool));
assert!(registry.get_tool("run_skill").is_some());
assert!(registry.get_tool("list_skills").is_some());
}
#[tokio::test]
async fn run_skill_loads_real_skill() {
let tool = RunSkillTool;
let result = tool.execute(json!({"name": "soroban"})).await;
match result {
Ok(content) => {
assert!(content.contains("Skill: soroban"));
assert!(content.contains("Soroban"));
}
Err(e) => {
assert!(e.contains("not found"), "unexpected error: {}", e);
}
}
}
#[test]
fn truncate_works() {
assert_eq!(truncate("short", 10), "short");
assert_eq!(truncate("this is a long description", 10), "this is...");
assert_eq!(truncate("exact", 5), "exact");
}
}