sac-cli 0.1.0

Terminal-based AI coding agent — fork of NAC with extended backend support and context management
Documentation
use super::*;

#[derive(Clone)]
pub struct SkillRegistry {
    skills: Arc<HashMap<String, SkillRecord>>,
}

impl SkillRegistry {
    pub fn load(
        workspace_dir: Option<&Path>,
        sandbox: Option<&SandboxSession>,
    ) -> Result<Option<Arc<Self>>> {
        let sources = discover_skill_sources(workspace_dir)?;
        if sources.is_empty() {
            return Ok(None);
        }

        let mut skills = HashMap::new();
        let mut shadowed = HashSet::new();

        for source in sources {
            let visible_root = match visible_root_for_source(&source, sandbox) {
                Some(path) => path,
                None => continue,
            };
            for skill_dir in discover_skill_dirs(&source.host_root)? {
                let skill_md_path = skill_dir.join(SKILL_FILENAME);
                let Some(parsed) = parse_skill_file(&skill_md_path)? else {
                    continue;
                };

                let relative = skill_dir
                    .strip_prefix(&source.host_root)
                    .unwrap_or_else(|_| Path::new(""));
                let skill_root_visible = join_path(&visible_root, relative);
                let record = SkillRecord {
                    name: parsed.name.clone(),
                    description: parsed.description,
                    compatibility: parsed.compatibility,
                    skill_md_path,
                    skill_root_host: skill_dir.clone(),
                    skill_root_visible,
                    body: parsed.body,
                    resources: list_skill_resources(&skill_dir)?,
                };

                if skills.contains_key(&parsed.name) {
                    shadowed.insert(parsed.name);
                    continue;
                }
                skills.insert(parsed.name.clone(), record);
            }
        }

        for name in shadowed {
            eprintln!(
                "Skill '{}' is shadowed by a higher-precedence definition",
                name
            );
        }

        if skills.is_empty() {
            return Ok(None);
        }

        Ok(Some(Arc::new(Self {
            skills: Arc::new(skills),
        })))
    }

    pub fn tool_definition(&self) -> ToolDefinition {
        let mut names: Vec<String> = self.skills.keys().cloned().collect();
        names.sort();
        ToolDefinition {
            def_type: "function".to_string(),
            function: FunctionDef {
                name: "activate_skill".to_string(),
                description:
                    "Load the full instructions for an available skill by name before proceeding."
                        .to_string(),
                parameters: json!({
                    "type": "object",
                    "properties": {
                        "name": {
                            "type": "string",
                            "enum": names,
                            "description": "Name of the skill to activate"
                        }
                    },
                    "required": ["name"]
                }),
            },
        }
    }

    pub fn catalog_message(&self) -> Option<String> {
        let catalog = self.catalog_entries();
        if catalog.is_empty() {
            return None;
        }

        let mut content = String::from(
            "The following skills provide specialized instructions for specific tasks. \
             When a task clearly matches a skill's description, call activate_skill(name) \
             before proceeding. After activation, follow the returned instructions and \
             resolve any relative paths against the returned skill directory.\n\n<available_skills>",
        );

        for entry in catalog {
            content.push_str("\n  <skill>");
            content.push_str(&format!("\n    <name>{}</name>", escape_xml(&entry.name)));
            content.push_str(&format!(
                "\n    <description>{}</description>",
                escape_xml(&entry.description)
            ));
            if let Some(compatibility) = entry.compatibility {
                content.push_str(&format!(
                    "\n    <compatibility>{}</compatibility>",
                    escape_xml(&compatibility)
                ));
            }
            content.push_str("\n  </skill>");
        }
        content.push_str("\n</available_skills>");
        Some(content)
    }

    pub fn catalog_entries(&self) -> Vec<SkillCatalogEntry> {
        let mut entries: Vec<SkillCatalogEntry> = self
            .skills
            .values()
            .map(|skill| SkillCatalogEntry {
                name: skill.name.clone(),
                description: skill.description.clone(),
                compatibility: skill.compatibility.clone(),
            })
            .collect();
        entries.sort_by(|left, right| left.name.cmp(&right.name));
        entries
    }

    pub fn has_skill(&self, name: &str) -> bool {
        self.skills.contains_key(name)
    }

    pub fn activate(&self, name: &str, already_active: bool) -> ToolResult {
        let Some(skill) = self.skills.get(name) else {
            return ToolResult {
                content: format!("Error: unknown skill '{}'", name),
                is_error: true,
            };
        };

        let mut content = String::new();
        if already_active {
            content.push_str(&format!(
                "<skill_content name=\"{}\" already_active=\"true\">\n",
                escape_xml(&skill.name)
            ));
            content.push_str(
                "This skill is already active in the current conversation, so its instructions are not being re-injected.\n\n",
            );
        } else {
            content.push_str(&format!(
                "<skill_content name=\"{}\">\n",
                escape_xml(&skill.name)
            ));
            if let Some(compatibility) = &skill.compatibility {
                content.push_str(&format!("Compatibility: {}\n\n", compatibility));
            }
            content.push_str(&skill.body);
            if !skill.body.ends_with('\n') {
                content.push('\n');
            }
            content.push('\n');
        }

        content.push_str(&format!(
            "Skill directory: {}\n",
            skill.skill_root_visible.display()
        ));
        content.push_str("Relative paths in this skill are relative to the skill directory.\n");
        if !skill.resources.is_empty() {
            content.push_str("<skill_resources>\n");
            for resource in &skill.resources {
                content.push_str(&format!("  <file>{}</file>\n", escape_xml(resource)));
            }
            content.push_str("</skill_resources>\n");
        }
        content.push_str("</skill_content>");

        ToolResult {
            content,
            is_error: false,
        }
    }
}

#[cfg(test)]
impl SkillRegistry {
    pub(crate) fn load_for_test(records: Vec<SkillRecord>) -> Self {
        let skills = records
            .into_iter()
            .map(|record| (record.name.clone(), record))
            .collect();
        Self {
            skills: Arc::new(skills),
        }
    }
}