//! `skill` tool — OpenCode variant of the skill tool. //! //! Loads a user-defined skill by name, reads its `SKILL.md` content, //! lists up to 10 bundled files from the skill directory, and returns //! the result wrapped in `` XML. use std::path::Path; use crate::implementations::skills::body_cap::cap_skill_body; use crate::implementations::skills::skill::{SkillOutput, extract_skill_body, format_skill_name}; use crate::implementations::skills::types::SkillInfo; use crate::types::requirements::{Expr, ToolRequirement}; #[allow(unused_imports)] use crate::types::resources::{AvailableSkills, SharedResources}; use crate::types::tool::{ToolKind, ToolNamespace}; const DESCRIPTION: &str = r#"Load a specialized skill that provides domain-specific instructions and workflows. When you recognize that a task matches one of the available skills listed below, use this tool to load the full skill instructions. The skill will inject detailed instructions, workflows, and access to bundled resources into the conversation via a `` block with the loaded content. The following skills provide specialized sets of instructions for particular tasks. Invoke this tool to load a skill when a task matches one of the available skills listed below: ${%- if skills %} ${%- for skill in skills %} ${{ skill.name }} ${{ skill.description|e }} ${{ skill.location }} ${%- endfor %} ${%- else %} (No skills available. Skills can be added in ~/.kigi/skills/ or .kigi/skills/) ${%- endif %} "#; #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, schemars::JsonSchema)] pub struct SkillInput { #[schemars(description = "The name of the skill to invoke")] pub name: String, } impl TryFrom for SkillInput { type Error = String; fn try_from(value: crate::types::tool_io::ToolInput) -> Result { match value { crate::types::tool_io::ToolInput::Dynamic(v) => { serde_json::from_value(v).map_err(|e| format!("SkillInput: {e}")) } _ => Err("expected Dynamic variant for SkillInput".into()), } } } impl From for crate::types::tool_io::ToolInput { fn from(value: SkillInput) -> Self { crate::types::tool_io::ToolInput::Dynamic( serde_json::to_value(value).expect("SkillInput serializes to JSON"), ) } } /// OpenCode skill tool — loads specialized skill instructions into the conversation. #[derive(Debug, Default)] pub struct SkillTool; /// Result of looking up a skill by name. #[derive(Debug)] enum FindSkillResult<'a> { Found(&'a SkillInfo), /// Multiple skills share the same short name -- caller must use a qualified name. Ambiguous(Vec), NotFound, } /// Find a skill by name from the available skills list. /// /// Supports both fully-qualified names (`"local:commit"`) and /// short names (`"commit"`). /// /// When a short name matches multiple skills across scopes, returns /// `Ambiguous` with the qualified names so the caller can ask for /// disambiguation instead of silently picking first-match. fn find_skill<'a>(name: &str, skills: &'a [SkillInfo]) -> FindSkillResult<'a> { // A fully-qualified name matches at most one skill. if let Some(skill) = skills .iter() .find(|s| s.enabled && format_skill_name(s) == name) { return FindSkillResult::Found(skill); } let matches: Vec<&SkillInfo> = skills .iter() .filter(|s| s.enabled && s.name == name) .collect(); match matches.len() { 0 => FindSkillResult::NotFound, 1 => FindSkillResult::Found(matches[0]), _ => { let qualified: Vec = matches.iter().map(|s| format_skill_name(s)).collect(); FindSkillResult::Ambiguous(qualified) } } } /// Load skill content from its SKILL.md file, stripping YAML frontmatter. async fn load_skill_content(skill: &SkillInfo) -> Result { let path = Path::new(&skill.path); match tokio::fs::read_to_string(path).await { Ok(content) => Ok(extract_skill_body(&content)), Err(e) => Err(format!("Failed to read skill file '{}': {}", skill.path, e)), } } /// List up to `limit` bundled files in the skill directory, excluding SKILL.md. async fn list_skill_files(skill: &SkillInfo, limit: usize) -> Vec { let skill_path = Path::new(&skill.path); let dir = match skill_path.parent() { Some(d) => d, None => return vec![], }; let mut entries = match tokio::fs::read_dir(dir).await { Ok(entries) => entries, Err(_) => return vec![], }; let mut files = Vec::new(); while let Ok(Some(entry)) = entries.next_entry().await { if files.len() >= limit { break; } let path = entry.path(); if path .file_name() .is_some_and(|n| n.eq_ignore_ascii_case("SKILL.md")) { continue; } files.push(path.display().to_string()); } files } /// Format the `` XML output block. fn format_output(skill: &SkillInfo, content: &str, files: &[String]) -> String { let base_dir = Path::new(&skill.path) .parent() .map(|p| format!("file://{}", p.display())) .unwrap_or_default(); let files_xml: String = files .iter() .map(|f| format!("{f}")) .collect::>() .join("\n"); let mut out = String::new(); out.push_str(&format!("\n", skill.name)); out.push_str(&format!("# Skill: {}\n\n", skill.name)); out.push_str(content.trim()); out.push_str("\n\n"); out.push_str(&format!("Base directory for this skill: {base_dir}\n")); out.push_str( "Relative paths in this skill (e.g., scripts/, reference/) are relative to this base directory.\n", ); out.push_str("Note: file list is sampled.\n\n"); out.push_str("\n"); out.push_str(&files_xml); out.push('\n'); out.push_str("\n"); out.push_str(""); out } impl crate::types::tool_metadata::ToolMetadata for SkillTool { fn kind(&self) -> ToolKind { ToolKind::Skill } fn tool_namespace(&self) -> ToolNamespace { ToolNamespace::OpenCode } fn description_template(&self) -> &str { DESCRIPTION } fn requires_expr(&self) -> Expr { Expr::True } } impl kigi_tool_runtime::Tool for SkillTool { type Args = SkillInput; type Output = SkillOutput; fn id(&self) -> kigi_tool_protocol::ToolId { kigi_tool_protocol::ToolId::new("skill").expect("valid tool id") } fn description( &self, _ctx: &::kigi_tool_runtime::ListToolsContext, ) -> kigi_tool_types::ToolDescription { kigi_tool_types::ToolDescription::new( "skill", crate::types::tool_metadata::ToolMetadata::description_template(self), ) } fn capabilities(&self) -> kigi_tool_protocol::ToolCapabilities { kigi_tool_protocol::ToolCapabilities { is_read_only: false, tool_scope: Some(kigi_tool_protocol::ToolScope::Write), ..Default::default() } } #[tracing::instrument(name = "tool.opencode_skill", skip_all, fields(skill_name = %input.name))] async fn run( &self, ctx: kigi_tool_runtime::ToolCallContext, input: SkillInput, ) -> Result { use crate::types::tool_metadata::shared_resources; let resources = shared_resources(&ctx)?; let available_skills; { available_skills = resources .lock() .await .get::() .map(|a| a.0.clone()) .unwrap_or_default(); } let skill = match find_skill(&input.name, &available_skills) { FindSkillResult::Found(s) => s.clone(), FindSkillResult::Ambiguous(qualified) => { return Ok(SkillOutput { success: false, tool_result: format!( "Skill '{}' is ambiguous -- multiple skills share this name. \ Use a qualified name: {}", input.name, qualified.join(", ") ), skill_name: input.name.clone(), skill_message: None, error: Some(format!( "Ambiguous skill '{}': use one of {}", input.name, qualified.join(", ") )), }); } FindSkillResult::NotFound => { let names: Vec<&str> = available_skills.iter().map(|s| s.name.as_str()).collect(); let available = if names.is_empty() { "none".to_string() } else { names.join(", ") }; return Ok(SkillOutput { success: false, tool_result: format!( "Skill \"{}\" not found. Available skills: {}", input.name, available ), skill_name: input.name.clone(), skill_message: None, error: Some(format!("Skill \"{}\" not found", input.name)), }); } }; let mut content = match load_skill_content(&skill).await { Ok(c) => c, Err(e) => { return Ok(SkillOutput { success: false, tool_result: format!("Failed to load skill '{}': {}", skill.name, e), skill_name: skill.name.clone(), skill_message: None, error: Some(e), }); } }; if cap_skill_body(&mut content) { tracing::info!( skill = %skill.name, path = %skill.path, "skill body truncated at read cap" ); } let files = list_skill_files(&skill, 10).await; let output = format_output(&skill, &content, &files); Ok(SkillOutput { success: true, tool_result: format!("Loaded skill: {}", skill.name), skill_name: skill.name.clone(), skill_message: Some(output), error: None, }) } } #[cfg(test)] mod tests { use super::*; use crate::types::tool_metadata::test_ctx; use crate::implementations::skills::types::SkillScope; use crate::types::resources::Resources; use tempfile::TempDir; fn make_test_skill(name: &str, scope: SkillScope, path: &str) -> SkillInfo { SkillInfo { name: name.to_string(), display_name: None, description: format!("Test skill: {}", name), short_description: None, author: None, argument_hint: None, path: path.to_string(), scope, config_source: None, plugin_name: None, plugin_version: None, plugin_root: None, plugin_data: None, allowed_tools: None, license: None, compatibility: None, metadata: None, model: None, effort: None, user_invocable: true, disable_model_invocation: false, when_to_use: None, has_user_specified_description: false, paths: None, enabled: true, body: None, } } #[test] fn tool_metadata() { use crate::types::tool_metadata::ToolMetadata; let tool = SkillTool; assert_eq!(kigi_tool_runtime::Tool::id(&tool).as_str(), "skill"); assert!(matches!(tool.kind(), ToolKind::Skill)); assert!(matches!(tool.tool_namespace(), ToolNamespace::OpenCode)); let desc = tool.description_template(); assert!(desc.contains("available_skills")); assert!(desc.contains("${%- for skill in skills %}")); assert!(desc.contains("${{ skill.name }}")); } #[test] fn find_skill_by_short_name() { let skills = vec![ make_test_skill("commit", SkillScope::User, "/path/a"), make_test_skill("build", SkillScope::Local, "/path/b"), ]; assert!( matches!(find_skill("commit", &skills), FindSkillResult::Found(s) if s.name == "commit") ); assert!( matches!(find_skill("build", &skills), FindSkillResult::Found(s) if s.name == "build") ); assert!(matches!( find_skill("nonexistent", &skills), FindSkillResult::NotFound )); } #[test] fn find_skill_by_qualified_name() { let skills = vec![ make_test_skill("commit", SkillScope::User, "/path/a"), make_test_skill("build", SkillScope::Local, "/path/b"), ]; assert!( matches!(find_skill("user:commit", &skills), FindSkillResult::Found(s) if s.name == "commit") ); assert!( matches!(find_skill("local:build", &skills), FindSkillResult::Found(s) if s.name == "build") ); } #[test] fn find_skill_ambiguous_short_name_rejected() { let skills = vec![ make_test_skill("commit", SkillScope::Local, "/path/local"), make_test_skill("commit", SkillScope::User, "/path/user"), ]; let result = find_skill("commit", &skills); match result { FindSkillResult::Ambiguous(qualified) => { assert!(qualified.contains(&"local:commit".to_string())); assert!(qualified.contains(&"user:commit".to_string())); } other => panic!("Expected Ambiguous, got {other:?}"), } } #[test] fn find_skill_qualified_resolves_despite_ambiguous_short_name() { let skills = vec![ make_test_skill("commit", SkillScope::Local, "/path/local"), make_test_skill("commit", SkillScope::User, "/path/user"), ]; assert!(matches!( find_skill("local:commit", &skills), FindSkillResult::Found(s) if s.path == "/path/local" )); assert!(matches!( find_skill("user:commit", &skills), FindSkillResult::Found(s) if s.path == "/path/user" )); } #[tokio::test] async fn skill_ambiguous_returns_error_output() { let mut resources = Resources::new(); resources.insert(AvailableSkills(vec![ make_test_skill("commit", SkillScope::Local, "/path/local"), make_test_skill("commit", SkillScope::User, "/path/user"), ])); let tool = SkillTool; let output = kigi_tool_runtime::Tool::run( &tool, test_ctx(resources.into_shared()), SkillInput { name: "commit".into(), }, ) .await .unwrap(); assert!(!output.success); assert!(output.tool_result.contains("ambiguous")); assert!(output.tool_result.contains("local:commit")); assert!(output.tool_result.contains("user:commit")); assert!(output.error.is_some()); assert!(output.skill_message.is_none()); } #[tokio::test] async fn skill_not_found_returns_error_output() { let mut resources = Resources::new(); resources.insert(AvailableSkills(vec![make_test_skill( "commit", SkillScope::User, "/path/a", )])); let tool = SkillTool; let output = kigi_tool_runtime::Tool::run( &tool, test_ctx(resources.into_shared()), SkillInput { name: "nonexistent".into(), }, ) .await .unwrap(); assert!(!output.success); assert!(output.tool_result.contains("not found")); assert!(output.tool_result.contains("commit")); assert!(output.error.is_some()); assert!(output.skill_message.is_none()); } #[tokio::test] async fn skill_not_found_with_no_skills_resource() { let resources = Resources::new(); let tool = SkillTool; let output = kigi_tool_runtime::Tool::run( &tool, test_ctx(resources.into_shared()), SkillInput { name: "commit".into(), }, ) .await .unwrap(); assert!(!output.success); assert!(output.tool_result.contains("not found")); assert!(output.tool_result.contains("none")); } #[tokio::test] async fn skill_loads_content_from_file() { let tmp = TempDir::new().unwrap(); let skill_path = tmp.path().join("SKILL.md"); std::fs::write( &skill_path, "---\nname: test\ndescription: A test\n---\n# Test Skill\n\nDo the thing.", ) .unwrap(); let mut resources = Resources::new(); resources.insert(AvailableSkills(vec![make_test_skill( "test", SkillScope::Local, skill_path.to_str().unwrap(), )])); let tool = SkillTool; let output = kigi_tool_runtime::Tool::run( &tool, test_ctx(resources.into_shared()), SkillInput { name: "test".into(), }, ) .await .unwrap(); assert!(output.success); assert_eq!(output.skill_name, "test"); assert_eq!(output.tool_result, "Loaded skill: test"); let msg = output.skill_message.unwrap(); assert!(msg.contains("")); assert!(msg.contains("# Skill: test")); assert!(msg.contains("# Test Skill")); assert!(msg.contains("Do the thing.")); assert!(msg.contains("")); assert!(msg.contains("")); } /// Regression: the OpenCode skill tool's body must reach the model via the /// normal tool result (`to_prompt_format()`). OpenCode registers the skill /// tool without a follow-up handler, so a body delivered only through such a /// handler would never reach the model. #[tokio::test] async fn skill_body_reaches_prompt_format() { use crate::types::output::ToolOutput; let tmp = TempDir::new().unwrap(); let skill_path = tmp.path().join("SKILL.md"); std::fs::write( &skill_path, "---\nname: test\ndescription: A test\n---\n# Test Skill\n\nDo the thing.", ) .unwrap(); let mut resources = Resources::new(); resources.insert(AvailableSkills(vec![make_test_skill( "test", SkillScope::Local, skill_path.to_str().unwrap(), )])); let tool = SkillTool; let output = kigi_tool_runtime::Tool::run( &tool, test_ctx(resources.into_shared()), SkillInput { name: "test".into(), }, ) .await .unwrap(); assert!(output.success); let prompt = ToolOutput::Skill(output).to_prompt_format(); assert!( prompt.contains("Do the thing."), "skill body must reach to_prompt_format(), got: {prompt}" ); assert!(prompt.contains("")); } #[tokio::test] async fn skill_lists_bundled_files() { let tmp = TempDir::new().unwrap(); let skill_path = tmp.path().join("SKILL.md"); std::fs::write(&skill_path, "# Skill\n\nContent.").unwrap(); std::fs::write(tmp.path().join("helper.sh"), "#!/bin/bash").unwrap(); std::fs::write(tmp.path().join("reference.md"), "# Ref").unwrap(); let mut resources = Resources::new(); resources.insert(AvailableSkills(vec![make_test_skill( "test", SkillScope::Local, skill_path.to_str().unwrap(), )])); let tool = SkillTool; let output = kigi_tool_runtime::Tool::run( &tool, test_ctx(resources.into_shared()), SkillInput { name: "test".into(), }, ) .await .unwrap(); assert!(output.success); let msg = output.skill_message.unwrap(); assert!(msg.contains("")); // SKILL.md itself should NOT appear in the file list. assert!(!msg.contains("") || !msg.contains("SKILL.md")); } #[tokio::test] async fn skill_file_not_found_returns_error() { let mut resources = Resources::new(); resources.insert(AvailableSkills(vec![make_test_skill( "missing", SkillScope::Local, "/nonexistent/path/SKILL.md", )])); let tool = SkillTool; let output = kigi_tool_runtime::Tool::run( &tool, test_ctx(resources.into_shared()), SkillInput { name: "missing".into(), }, ) .await .unwrap(); assert!(!output.success); assert!(output.tool_result.contains("Failed to load")); assert!(output.error.is_some()); } #[tokio::test] async fn works_through_erased_interface() { let tmp = TempDir::new().unwrap(); let skill_path = tmp.path().join("SKILL.md"); std::fs::write(&skill_path, "# Hello\n\nContent.").unwrap(); let mut resources = Resources::new(); resources.insert(AvailableSkills(vec![make_test_skill( "hello", SkillScope::Local, skill_path.to_str().unwrap(), )])); let tool = SkillTool; let result = kigi_tool_runtime::Tool::run( &tool, test_ctx(resources.into_shared()), SkillInput { name: "hello".into(), }, ) .await .unwrap(); assert!(result.success); assert_eq!(result.skill_name, "hello"); } #[tokio::test] async fn frontmatter_stripping() { let tmp = TempDir::new().unwrap(); let skill_path = tmp.path().join("SKILL.md"); std::fs::write( &skill_path, "---\nname: deploy\ndescription: Deploy to prod\ntags: [ops]\n---\nRun the deploy pipeline.", ) .unwrap(); let mut resources = Resources::new(); resources.insert(AvailableSkills(vec![make_test_skill( "deploy", SkillScope::Local, skill_path.to_str().unwrap(), )])); let tool = SkillTool; let output = kigi_tool_runtime::Tool::run( &tool, test_ctx(resources.into_shared()), SkillInput { name: "deploy".into(), }, ) .await .unwrap(); assert!(output.success); let msg = output.skill_message.unwrap(); assert!(msg.contains("Run the deploy pipeline.")); assert!(!msg.contains("---")); assert!(!msg.contains("tags: [ops]")); assert!(!msg.contains("description: Deploy to prod")); } #[tokio::test] async fn skill_message_xml_structure() { let tmp = TempDir::new().unwrap(); let skill_path = tmp.path().join("SKILL.md"); std::fs::write(&skill_path, "---\nname: fmt\n---\nFormat the code.").unwrap(); std::fs::write(tmp.path().join("fmt.sh"), "#!/bin/bash").unwrap(); let mut resources = Resources::new(); resources.insert(AvailableSkills(vec![make_test_skill( "fmt", SkillScope::Local, skill_path.to_str().unwrap(), )])); let tool = SkillTool; let output = kigi_tool_runtime::Tool::run( &tool, test_ctx(resources.into_shared()), SkillInput { name: "fmt".into() }, ) .await .unwrap(); assert!(output.success); let msg = output.skill_message.unwrap(); assert!(msg.starts_with("\n")); assert!(msg.contains("# Skill: fmt\n")); assert!(msg.contains("Format the code.")); assert!(msg.contains(&format!( "Base directory for this skill: file://{}", tmp.path().display() ))); assert!(msg.contains("\n")); assert!(msg.contains(&format!( "{}", tmp.path().join("fmt.sh").display() ))); assert!(msg.ends_with("")); } #[tokio::test] async fn base_directory_in_output() { let tmp = TempDir::new().unwrap(); let skill_dir = tmp.path().join("my-skills").join("linter"); std::fs::create_dir_all(&skill_dir).unwrap(); let skill_path = skill_dir.join("SKILL.md"); std::fs::write(&skill_path, "Lint all files.").unwrap(); let mut resources = Resources::new(); resources.insert(AvailableSkills(vec![make_test_skill( "linter", SkillScope::User, skill_path.to_str().unwrap(), )])); let tool = SkillTool; let output = kigi_tool_runtime::Tool::run( &tool, test_ctx(resources.into_shared()), SkillInput { name: "linter".into(), }, ) .await .unwrap(); assert!(output.success); let msg = output.skill_message.unwrap(); let expected_base = format!( "Base directory for this skill: file://{}", skill_dir.display() ); assert!( msg.contains(&expected_base), "Expected base dir line:\n {expected_base}\nFull message:\n{msg}" ); assert!(msg.contains("Relative paths in this skill")); } #[tokio::test] async fn skill_with_no_bundled_files() { let tmp = TempDir::new().unwrap(); let skill_path = tmp.path().join("SKILL.md"); std::fs::write(&skill_path, "Solo skill, no extras.").unwrap(); let mut resources = Resources::new(); resources.insert(AvailableSkills(vec![make_test_skill( "solo", SkillScope::Local, skill_path.to_str().unwrap(), )])); let tool = SkillTool; let output = kigi_tool_runtime::Tool::run( &tool, test_ctx(resources.into_shared()), SkillInput { name: "solo".into(), }, ) .await .unwrap(); assert!(output.success); let msg = output.skill_message.unwrap(); assert!(msg.contains("")); assert!(msg.contains("")); assert!(!msg.contains("")); } #[tokio::test] async fn empty_skill_content() { let tmp = TempDir::new().unwrap(); let skill_path = tmp.path().join("SKILL.md"); std::fs::write(&skill_path, "---\nname: empty\ndescription: nothing\n---\n").unwrap(); let mut resources = Resources::new(); resources.insert(AvailableSkills(vec![make_test_skill( "empty", SkillScope::Local, skill_path.to_str().unwrap(), )])); let tool = SkillTool; let output = kigi_tool_runtime::Tool::run( &tool, test_ctx(resources.into_shared()), SkillInput { name: "empty".into(), }, ) .await .unwrap(); assert!(output.success); assert_eq!(output.tool_result, "Loaded skill: empty"); assert!(output.error.is_none()); let msg = output.skill_message.unwrap(); assert!(msg.contains("")); assert!(msg.contains("# Skill: empty")); assert!(msg.contains("")); assert!(!msg.contains("description: nothing")); } #[tokio::test] async fn ten_file_cap() { let tmp = TempDir::new().unwrap(); let skill_path = tmp.path().join("SKILL.md"); std::fs::write( &skill_path, "---\nname: bigskill\ndescription: lots of files\n---\nDo stuff.", ) .unwrap(); for i in 1..=15 { std::fs::write( tmp.path().join(format!("file{i:02}.txt")), format!("content {i}"), ) .unwrap(); } let mut resources = Resources::new(); resources.insert(AvailableSkills(vec![make_test_skill( "bigskill", SkillScope::Local, skill_path.to_str().unwrap(), )])); let tool = SkillTool; let output = kigi_tool_runtime::Tool::run( &tool, test_ctx(resources.into_shared()), SkillInput { name: "bigskill".into(), }, ) .await .unwrap(); assert!(output.success); let msg = output.skill_message.unwrap(); let file_tag_count = msg.matches("").count(); assert!( file_tag_count <= 10, "Expected at most 10 entries, got {file_tag_count}" ); } }