procyon 0.1.2

Terminal development harness for Stellar and Soroban smart contracts, driven by a language model
use async_trait::async_trait;
use serde_json::{json, Value};

use std::sync::Arc;

use super::Tool;
use crate::skills::SkillRegistry;

/// Returns the shared skill registry, discovered from disk on first use.
async fn load_registry() -> Arc<SkillRegistry> {
    crate::registries::skills().await
}

/// Tool: run_skill
///
/// Loads a skill by name and returns its body as context for the LLM.
/// The skill content is rendered as a system prompt addition — the LLM
/// uses it as instructions for how to behave.
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(", ")
                )
            }
        })?;

        // The provenance line is part of the result, not a log entry beside it: this string is
        // what reaches the conversation and the session log, so it is the only place a later reader
        // can find out which file the agent was following and what that file said at the time.
        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)
    }
}

/// Tool: list_skills
///
/// Lists all discovered skills with their names and descriptions.
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 {
                ""
            };
            // Version and digest here too, so the model can say which one it is about to load and
            // a reader can tell two installs of the same name apart.
            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() {
        // Only runs if stellar-build skills are installed
        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) => {
                // Skill not installed — that's OK for testing
                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");
    }
}