use anyhow::Result;
use async_trait::async_trait;
use serde_json::json;
use std::path::PathBuf;
use std::sync::{Arc, RwLock};
use super::helpers;
use crate::config::NaviConfig;
use crate::skills::{SkillManifest, discover_configured_skills};
use crate::tool::{Tool, ToolDefinition, ToolInvocation, ToolKind, ToolResult};
pub(crate) struct SkillTool {
project_dir: PathBuf,
data_dir: PathBuf,
config: Arc<RwLock<NaviConfig>>,
}
impl SkillTool {
pub(crate) fn new(
project_dir: PathBuf,
data_dir: PathBuf,
config: Arc<RwLock<NaviConfig>>,
) -> Self {
Self {
project_dir,
data_dir,
config,
}
}
fn discover(&self) -> Result<Vec<SkillManifest>> {
let config = self
.config
.read()
.unwrap_or_else(|e| e.into_inner())
.clone();
discover_configured_skills(&config.skills, &self.project_dir, &self.data_dir)
}
}
#[async_trait]
impl Tool for SkillTool {
fn definition(&self) -> ToolDefinition {
helpers::definition(
"load_skill",
"Load the instruction text for one available NAVI skill by id or name. Use this only after deciding the skill is relevant from the Available Skills catalog.",
ToolKind::Read,
helpers::json_schema(
&[
("id", "Skill id from the Available Skills catalog."),
("name", "Skill name from the Available Skills catalog."),
(
"skill",
"Skill id or name from the Available Skills catalog.",
),
],
&[],
),
)
}
async fn invoke(&self, invocation: ToolInvocation) -> Result<ToolResult> {
let requested = invocation
.input
.get("id")
.or_else(|| invocation.input.get("skill"))
.or_else(|| invocation.input.get("name"))
.and_then(|value| value.as_str())
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| anyhow::anyhow!("load_skill requires `id`, `skill`, or `name`"))?;
let skills = self.discover()?;
let skill = skills
.iter()
.find(|skill| skill.id == requested || skill.name == requested)
.ok_or_else(|| {
let available: Vec<&str> = skills.iter().map(|skill| skill.id.as_str()).collect();
anyhow::anyhow!(
"skill `{}` was not found. Available skill ids: {}",
requested,
available.join(", ")
)
})?;
Ok(helpers::ok(
invocation.id,
json!({
"id": skill.id,
"name": skill.name,
"description": skill.description,
"version": skill.version,
"author": skill.author,
"tags": skill.tags,
"requires": skill.requires,
"instructions": skill.instructions,
}),
))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::SkillsConfig;
use crate::skills::{SkillWriteRequest, SkillWriteScope, write_skill};
#[tokio::test]
async fn load_skill_returns_instructions_for_requested_skill() {
let tempdir = tempfile::tempdir().expect("tempdir");
write_skill(
&SkillWriteRequest {
id: "reviewer".into(),
name: "Reviewer".into(),
description: Some("Reviews code".into()),
version: None,
author: None,
tags: vec![],
requires: vec![],
allow_tools: vec![],
deny_tools: vec![],
instructions: "# Reviewer\nReview carefully.".into(),
scope: SkillWriteScope::User,
},
tempdir.path(),
tempdir.path(),
)
.expect("write skill");
let mut config = NaviConfig::default();
config.skills = SkillsConfig {
enabled: true,
active: Vec::new(),
};
let tool = SkillTool::new(
tempdir.path().to_path_buf(),
tempdir.path().to_path_buf(),
Arc::new(RwLock::new(config)),
);
let result = tool
.invoke(ToolInvocation {
id: "call-1".to_string(),
tool_name: "load_skill".to_string(),
input: json!({ "id": "reviewer" }),
})
.await
.expect("invoke");
assert!(result.ok);
assert_eq!(result.output["id"], "reviewer");
assert_eq!(result.output["name"], "Reviewer");
assert_eq!(
result.output["instructions"],
"# Reviewer\nReview carefully."
);
}
}