diff --git a/README.md b/README.md index dbdceef..1dbb9b6 100644 --- a/README.md +++ b/README.md @@ -97,6 +97,53 @@ The model respects your .gitignore file and will treat ignored files as if they Working with git is strongly recommended. Models can occasionally damage code while attempting to implement features, and having version control makes recovery trivial. A productive workflow starts from a clean git state, lets the AI make progress on a feature, and commits only once the implementation is complete and working. If something goes wrong during development, you can simply revert the changes and try a different approach. +## Skills + +Tycode supports Claude Code Agent Skills - modular capabilities that extend the agent with specialized workflows. Skills are automatically discovered and can be invoked when the AI detects a matching request. + +### Skill Discovery + +Skills are discovered from the following locations (in priority order): + +1. `~/.claude/skills/` - User-level Claude Code compatibility +2. `~/.tycode/skills/` - User-level Tycode skills +3. `.claude/skills/` in workspace - Project-level Claude Code compatibility +4. `.tycode/skills/` in workspace - Project-level (highest priority) + +### Creating a Skill + +Each skill is a directory containing a `SKILL.md` file with YAML frontmatter: + +```markdown +--- +name: my-skill +description: When to use this skill +--- + +# My Skill Instructions + +Step-by-step instructions for the AI to follow... +``` + +### Using Skills + +List available skills: +```bash +/skills +``` + +Manually invoke a skill: +```bash +/skill +``` + +View skill details: +```bash +/skills info +``` + +Skills are also automatically invoked when the AI detects a user request matching a skill's description. + ## MCP Server Configuration Tycode supports locally running MCP servers over stdio transport. You can add or remove MCP servers using slash commands. diff --git a/tycode-cli/Cargo.toml b/tycode-cli/Cargo.toml index 9358e82..7c3714e 100644 --- a/tycode-cli/Cargo.toml +++ b/tycode-cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "tycode-cli" -version = "0.3.6" +version = "0.4.0" edition = "2021" authors = ["tigy"] description = "CLI interface for TyCode" diff --git a/tycode-core/Cargo.toml b/tycode-core/Cargo.toml index b287f54..9d1cb68 100644 --- a/tycode-core/Cargo.toml +++ b/tycode-core/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "tycode-core" -version = "0.3.6" +version = "0.4.0" edition = "2021" authors = ["tigy"] description = "Core chat actor and AI functionality for TyCode" diff --git a/tycode-core/src/agents/coder.rs b/tycode-core/src/agents/coder.rs index 5289709..e344cbf 100644 --- a/tycode-core/src/agents/coder.rs +++ b/tycode-core/src/agents/coder.rs @@ -3,6 +3,7 @@ use crate::context::tracked_files::TrackedFilesManager; use crate::memory::AppendMemoryTool; use crate::modules::execution::RunBuildTestTool; use crate::prompt::{autonomy, PromptComponentSelection}; +use crate::skills::tool::InvokeSkillTool; use crate::tools::analyzer::get_type_docs::GetTypeDocsTool; use crate::tools::analyzer::search_types::SearchTypesTool; use crate::tools::complete_task::CompleteTask; @@ -62,6 +63,7 @@ impl Agent for CoderAgent { SearchTypesTool::tool_name(), GetTypeDocsTool::tool_name(), AppendMemoryTool::tool_name(), + InvokeSkillTool::tool_name(), ] } diff --git a/tycode-core/src/agents/coordinator.rs b/tycode-core/src/agents/coordinator.rs index 77618b0..a064ad3 100644 --- a/tycode-core/src/agents/coordinator.rs +++ b/tycode-core/src/agents/coordinator.rs @@ -3,6 +3,7 @@ use crate::context::tracked_files::TrackedFilesManager; use crate::memory::AppendMemoryTool; use crate::modules::execution::RunBuildTestTool; use crate::modules::task_list::ManageTaskListTool; +use crate::skills::tool::InvokeSkillTool; use crate::tools::analyzer::get_type_docs::GetTypeDocsTool; use crate::tools::analyzer::search_types::SearchTypesTool; use crate::tools::complete_task::CompleteTask; @@ -76,6 +77,7 @@ impl Agent for CoordinatorAgent { SearchTypesTool::tool_name(), GetTypeDocsTool::tool_name(), AppendMemoryTool::tool_name(), + InvokeSkillTool::tool_name(), ] } } diff --git a/tycode-core/src/agents/one_shot.rs b/tycode-core/src/agents/one_shot.rs index de3128c..1211050 100644 --- a/tycode-core/src/agents/one_shot.rs +++ b/tycode-core/src/agents/one_shot.rs @@ -3,6 +3,7 @@ use crate::context::tracked_files::TrackedFilesManager; use crate::memory::AppendMemoryTool; use crate::modules::execution::RunBuildTestTool; use crate::modules::task_list::ManageTaskListTool; +use crate::skills::tool::InvokeSkillTool; use crate::tools::analyzer::get_type_docs::GetTypeDocsTool; use crate::tools::analyzer::search_types::SearchTypesTool; use crate::tools::ask_user_question::AskUserQuestion; @@ -77,6 +78,7 @@ impl Agent for OneShotAgent { SearchTypesTool::tool_name(), GetTypeDocsTool::tool_name(), AppendMemoryTool::tool_name(), + InvokeSkillTool::tool_name(), ] } } diff --git a/tycode-core/src/chat/actor.rs b/tycode-core/src/chat/actor.rs index 21eb952..0e16b59 100644 --- a/tycode-core/src/chat/actor.rs +++ b/tycode-core/src/chat/actor.rs @@ -28,6 +28,7 @@ use crate::{ execution::{CommandResult, ExecutionModule}, task_list::TaskListModule, }, + skills::SkillsModule, prompt::{ communication::CommunicationComponent, style::StyleMandatesComponent, tools::ToolInstructionsComponent, PromptBuilder, PromptComponent, @@ -198,6 +199,15 @@ impl ChatActorBuilder { ); builder.install_module_components(&*execution_module); + // Install skills module + let home_dir = dirs::home_dir().expect("Failed to get home directory"); + let skills_module = Arc::new(SkillsModule::new( + &builder.workspace_roots, + &home_dir, + &settings.skills, + )); + builder.install_module_components(&*skills_module); + builder.context_builder.add(file_tree_manager); builder.context_builder.add(tracked_files_manager.clone()); builder.context_builder.add(memories_manager.clone()); diff --git a/tycode-core/src/chat/commands.rs b/tycode-core/src/chat/commands.rs index 2027af8..3d51770 100644 --- a/tycode-core/src/chat/commands.rs +++ b/tycode-core/src/chat/commands.rs @@ -18,6 +18,7 @@ use crate::chat::{ use crate::context::ContextComponentSelection; use crate::settings::config::FileModificationApi; use crate::settings::config::{McpServerConfig, ProviderConfig, ReviewLevel}; +use crate::skills::SkillsModule; use chrono::Utc; use dirs; use serde_json::json; @@ -213,6 +214,8 @@ pub async fn process_command(state: &mut ActorState, command: &str) -> Vec handle_sessions_command(state, &parts).await, "debug_ui" => handle_debug_ui_command(state).await, "memory" => handle_memory_command(state, &parts).await, + "skills" => handle_skills_command(state, &parts).await, + "skill" => handle_skill_invoke_command(state, &parts).await, _ => vec![create_message( format!("Unknown command: /{}", parts[0]), MessageSender::Error, @@ -345,6 +348,18 @@ pub fn get_available_commands() -> Vec { usage: "/memory summarize".to_string(), hidden: false, }, + CommandInfo { + name: "skills".to_string(), + description: "List and manage available skills".to_string(), + usage: "/skills [info |reload]".to_string(), + hidden: false, + }, + CommandInfo { + name: "skill".to_string(), + description: "Manually invoke a skill".to_string(), + usage: "/skill ".to_string(), + hidden: false, + }, ] } @@ -2205,3 +2220,189 @@ async fn handle_sessions_gc_command(state: &ActorState, parts: &[&str]) -> Vec Vec { + let home_dir = match dirs::home_dir() { + Some(dir) => dir, + None => { + return vec![create_message( + "Failed to get home directory".to_string(), + MessageSender::Error, + )]; + } + }; + + let settings = state.settings.settings(); + let skills_module = SkillsModule::new(&state.workspace_roots, &home_dir, &settings.skills); + + if parts.len() < 2 { + // List all skills + let skills = skills_module.get_all_skills(); + + if skills.is_empty() { + return vec![create_message( + "No skills found. Skills are discovered from (in priority order):\n\ + - ~/.claude/skills/ (user-level Claude Code compatibility)\n\ + - ~/.tycode/skills/ (user-level)\n\ + - .claude/skills/ (project-level Claude Code compatibility)\n\ + - .tycode/skills/ (project-level, highest priority)\n\n\ + Each skill should be a directory containing a SKILL.md file." + .to_string(), + MessageSender::System, + )]; + } + + let mut message = format!("Available Skills ({} found):\n\n", skills.len()); + for skill in &skills { + let status = if skill.enabled { "" } else { " [disabled]" }; + message.push_str(&format!( + " {} ({}){}\n {}\n\n", + skill.name, skill.source, status, skill.description + )); + } + + message.push_str("Use `/skill ` to invoke a skill manually.\n"); + message.push_str("Use `/skills info ` to see skill details.\n"); + message.push_str("Use `/skills reload` to re-scan skill directories."); + + return vec![create_message(message, MessageSender::System)]; + } + + match parts[1] { + "info" => handle_skills_info_command(&skills_module, parts).await, + "reload" => { + skills_module.reload(); + let count = skills_module.get_all_skills().len(); + vec![create_message( + format!("Skills reloaded. Found {} skill(s).", count), + MessageSender::System, + )] + } + _ => vec![create_message( + "Usage: /skills [info |reload]\n\ + Use `/skills` to list all available skills." + .to_string(), + MessageSender::Error, + )], + } +} + +async fn handle_skills_info_command( + skills_module: &SkillsModule, + parts: &[&str], +) -> Vec { + if parts.len() < 3 { + return vec![create_message( + "Usage: /skills info ".to_string(), + MessageSender::Error, + )]; + } + + let name = parts[2]; + match skills_module.get_skill(name) { + Some(skill) => { + let mut message = format!("# Skill: {}\n\n", skill.metadata.name); + message.push_str(&format!("**Source:** {}\n", skill.metadata.source)); + message.push_str(&format!("**Path:** {}\n", skill.metadata.path.display())); + message.push_str(&format!( + "**Status:** {}\n\n", + if skill.metadata.enabled { + "Enabled" + } else { + "Disabled" + } + )); + message.push_str(&format!("**Description:**\n{}\n\n", skill.metadata.description)); + message.push_str("**Instructions:**\n\n"); + message.push_str(&skill.instructions); + + if !skill.reference_files.is_empty() { + message.push_str("\n\n**Reference Files:**\n"); + for file in &skill.reference_files { + message.push_str(&format!("- {}\n", file.display())); + } + } + + if !skill.scripts.is_empty() { + message.push_str("\n**Scripts:**\n"); + for script in &skill.scripts { + message.push_str(&format!("- {}\n", script.display())); + } + } + + vec![create_message(message, MessageSender::System)] + } + None => vec![create_message( + format!("Skill '{}' not found. Use `/skills` to list available skills.", name), + MessageSender::Error, + )], + } +} + +async fn handle_skill_invoke_command(state: &ActorState, parts: &[&str]) -> Vec { + if parts.len() < 2 { + return vec![create_message( + "Usage: /skill \n\ + Use `/skills` to list available skills." + .to_string(), + MessageSender::Error, + )]; + } + + let name = parts[1]; + let home_dir = match dirs::home_dir() { + Some(dir) => dir, + None => { + return vec![create_message( + "Failed to get home directory".to_string(), + MessageSender::Error, + )]; + } + }; + + let settings = state.settings.settings(); + let skills_module = SkillsModule::new(&state.workspace_roots, &home_dir, &settings.skills); + + match skills_module.get_skill(name) { + Some(skill) => { + if !skill.metadata.enabled { + return vec![create_message( + format!("Skill '{}' is disabled.", name), + MessageSender::Error, + )]; + } + + let mut message = format!( + "## Skill Invoked: {}\n\n{}\n\n---\n\n**Instructions:**\n\n{}", + skill.metadata.name, skill.metadata.description, skill.instructions + ); + + if !skill.reference_files.is_empty() { + message.push_str("\n\n**Reference Files:**\n"); + for file in &skill.reference_files { + message.push_str(&format!("- {}\n", file.display())); + } + } + + if !skill.scripts.is_empty() { + message.push_str("\n**Scripts:**\n"); + for script in &skill.scripts { + message.push_str(&format!("- {}\n", script.display())); + } + } + + vec![create_message(message, MessageSender::System)] + } + None => vec![create_message( + format!( + "Skill '{}' not found. Use `/skills` to list available skills.", + name + ), + MessageSender::Error, + )], + } +} diff --git a/tycode-core/src/lib.rs b/tycode-core/src/lib.rs index 9a8f53e..0787249 100644 --- a/tycode-core/src/lib.rs +++ b/tycode-core/src/lib.rs @@ -11,6 +11,7 @@ pub mod modules; pub mod persistence; pub mod prompt; pub mod settings; +pub mod skills; pub mod steering; pub mod tools; #[cfg(feature = "voice")] diff --git a/tycode-core/src/settings/config.rs b/tycode-core/src/settings/config.rs index 21525d6..c543ef8 100644 --- a/tycode-core/src/settings/config.rs +++ b/tycode-core/src/settings/config.rs @@ -1,6 +1,7 @@ use crate::ai::{model::ModelCost, types::ModelSettings}; use serde::{Deserialize, Serialize}; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; +use std::path::PathBuf; fn is_default_file_modification_api(api: &FileModificationApi) -> bool { api == &FileModificationApi::Default @@ -133,6 +134,45 @@ impl Default for VoiceSettings { } } +/// Configuration for the skills system. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SkillsConfig { + /// Master switch to enable/disable skills + #[serde(default = "default_skills_enabled")] + pub enabled: bool, + + /// Skills to disable by name + #[serde(default)] + pub disabled_skills: HashSet, + + /// Additional directories to search for skills + #[serde(default)] + pub additional_dirs: Vec, + + /// Load skills from ~/.claude/skills/ for Claude Code compatibility + #[serde(default = "default_claude_code_compat")] + pub enable_claude_code_compat: bool, +} + +fn default_skills_enabled() -> bool { + true +} + +fn default_claude_code_compat() -> bool { + true +} + +impl Default for SkillsConfig { + fn default() -> Self { + Self { + enabled: default_skills_enabled(), + disabled_skills: HashSet::new(), + additional_dirs: Vec::new(), + enable_claude_code_compat: default_claude_code_compat(), + } + } +} + /// Core application settings. /// /// # Maintainer Note @@ -218,6 +258,10 @@ pub struct Settings { /// Command execution mode (direct exec vs bash wrapper) #[serde(default)] pub command_execution_mode: CommandExecutionMode, + + /// Skills system configuration + #[serde(default)] + pub skills: SkillsConfig, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -299,6 +343,7 @@ impl Default for Settings { autonomy_level: AutonomyLevel::default(), voice: VoiceSettings::default(), command_execution_mode: CommandExecutionMode::default(), + skills: SkillsConfig::default(), } } } diff --git a/tycode-core/src/skills/context.rs b/tycode-core/src/skills/context.rs new file mode 100644 index 0000000..4b9b121 --- /dev/null +++ b/tycode-core/src/skills/context.rs @@ -0,0 +1,137 @@ +use std::sync::{Arc, RwLock}; + +use crate::context::{ContextComponent, ContextComponentId}; + +/// Context component ID for skills. +pub const SKILLS_CONTEXT_ID: ContextComponentId = ContextComponentId("skills"); + +/// Tracks which skills have been invoked in the current session. +pub struct InvokedSkillsState { + /// Skills that have been invoked, with their instructions + invoked: RwLock>, +} + +/// Represents a skill that has been invoked. +#[derive(Clone)] +pub struct InvokedSkill { + pub name: String, + pub instructions: String, +} + +impl InvokedSkillsState { + pub fn new() -> Self { + Self { + invoked: RwLock::new(Vec::new()), + } + } + + /// Records that a skill has been invoked. + pub fn add_invoked(&self, name: String, instructions: String) { + let mut invoked = self.invoked.write().unwrap(); + // Check if already invoked (don't duplicate) + if !invoked.iter().any(|s| s.name == name) { + invoked.push(InvokedSkill { name, instructions }); + } + } + + /// Clears all invoked skills (e.g., when starting a new conversation). + pub fn clear(&self) { + self.invoked.write().unwrap().clear(); + } + + /// Returns the list of invoked skills. + pub fn get_invoked(&self) -> Vec { + self.invoked.read().unwrap().clone() + } + + /// Checks if a skill has been invoked. + pub fn is_invoked(&self, name: &str) -> bool { + self.invoked.read().unwrap().iter().any(|s| s.name == name) + } +} + +impl Default for InvokedSkillsState { + fn default() -> Self { + Self::new() + } +} + +/// Context component that shows currently active skills. +/// +/// This shows which skills have been invoked in the current session, +/// including their full instructions. +pub struct SkillsContextComponent { + state: Arc, +} + +impl SkillsContextComponent { + pub fn new(state: Arc) -> Self { + Self { state } + } +} + +#[async_trait::async_trait(?Send)] +impl ContextComponent for SkillsContextComponent { + fn id(&self) -> ContextComponentId { + SKILLS_CONTEXT_ID + } + + async fn build_context_section(&self) -> Option { + let invoked = self.state.get_invoked(); + + if invoked.is_empty() { + return None; + } + + let mut output = String::new(); + output.push_str("## Active Skills\n\n"); + output.push_str("The following skills have been loaded for this task:\n\n"); + + for skill in &invoked { + output.push_str(&format!("### Skill: {}\n\n", skill.name)); + output.push_str(&skill.instructions); + output.push_str("\n\n---\n\n"); + } + + Some(output) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn test_context_with_invoked_skills() { + let state = Arc::new(InvokedSkillsState::new()); + state.add_invoked( + "commit".to_string(), + "# Commit Skill\n\nInstructions for committing.".to_string(), + ); + + let component = SkillsContextComponent::new(state); + let context = component.build_context_section().await.unwrap(); + + assert!(context.contains("## Active Skills")); + assert!(context.contains("### Skill: commit")); + assert!(context.contains("Instructions for committing")); + } + + #[tokio::test] + async fn test_context_without_invoked_skills() { + let state = Arc::new(InvokedSkillsState::new()); + let component = SkillsContextComponent::new(state); + let context = component.build_context_section().await; + + assert!(context.is_none()); + } + + #[test] + fn test_no_duplicate_invocations() { + let state = InvokedSkillsState::new(); + state.add_invoked("commit".to_string(), "Instructions 1".to_string()); + state.add_invoked("commit".to_string(), "Instructions 2".to_string()); + + assert_eq!(state.get_invoked().len(), 1); + } +} diff --git a/tycode-core/src/skills/discovery.rs b/tycode-core/src/skills/discovery.rs new file mode 100644 index 0000000..0a10430 --- /dev/null +++ b/tycode-core/src/skills/discovery.rs @@ -0,0 +1,347 @@ +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, RwLock}; + +use anyhow::Result; +use tracing::{debug, warn}; + +use super::parser::parse_skill_file; +use super::types::{SkillInstructions, SkillMetadata, SkillSource}; +use crate::settings::config::SkillsConfig; + +const SKILL_FILE_NAME: &str = "SKILL.md"; + +/// Manages skill discovery and loading. +/// +/// SkillsManager discovers skills from multiple directories (in priority order): +/// 1. `~/.claude/skills/` (user-level Claude Code compatibility, lowest priority) +/// 2. `~/.tycode/skills/` (user-level) +/// 3. `.claude/skills/` in each workspace (project-level Claude Code compatibility) +/// 4. `.tycode/skills/` in each workspace (project-level, highest priority) +/// +/// Later sources override earlier ones if the same skill name is found. +pub struct SkillsManager { + inner: Arc, +} + +struct SkillsManagerInner { + /// All discovered skills indexed by name + skills: RwLock>, + /// Configuration + config: SkillsConfig, + /// Workspace roots for project-level skill discovery + workspace_roots: Vec, + /// Home directory + home_dir: PathBuf, +} + +impl SkillsManager { + /// Discovers skills from all configured directories. + pub fn discover( + workspace_roots: &[PathBuf], + home_dir: &Path, + config: &SkillsConfig, + ) -> Self { + let inner = Arc::new(SkillsManagerInner { + skills: RwLock::new(HashMap::new()), + config: config.clone(), + workspace_roots: workspace_roots.to_vec(), + home_dir: home_dir.to_path_buf(), + }); + + let manager = Self { inner }; + + if config.enabled { + manager.reload(); + } + + manager + } + + /// Reloads skills from all directories. + pub fn reload(&self) { + let mut skills = HashMap::new(); + + // 1. Load from ~/.claude/skills/ (Claude Code compatibility, lowest priority) + if self.inner.config.enable_claude_code_compat { + let claude_skills_dir = self.inner.home_dir.join(".claude").join("skills"); + if claude_skills_dir.is_dir() { + debug!("Discovering skills from {:?}", claude_skills_dir); + self.discover_from_directory(&claude_skills_dir, SkillSource::ClaudeCode, &mut skills); + } + } + + // 2. Load from ~/.tycode/skills/ (user-level) + let user_skills_dir = self.inner.home_dir.join(".tycode").join("skills"); + if user_skills_dir.is_dir() { + debug!("Discovering skills from {:?}", user_skills_dir); + self.discover_from_directory(&user_skills_dir, SkillSource::User, &mut skills); + } + + // 3. Load from additional directories configured in settings + for dir in &self.inner.config.additional_dirs { + if dir.is_dir() { + debug!("Discovering skills from additional dir {:?}", dir); + self.discover_from_directory(dir, SkillSource::User, &mut skills); + } + } + + // 4. Load from .tycode/skills/ and .claude/skills/ in each workspace root (project-level, highest priority) + for workspace_root in &self.inner.workspace_roots { + // Check .tycode/skills/ + let tycode_skills_dir = workspace_root.join(".tycode").join("skills"); + if tycode_skills_dir.is_dir() { + debug!("Discovering skills from {:?}", tycode_skills_dir); + self.discover_from_directory( + &tycode_skills_dir, + SkillSource::Project(workspace_root.clone()), + &mut skills, + ); + } + + // Check .claude/skills/ (Claude Code project-level compatibility) + if self.inner.config.enable_claude_code_compat { + let claude_skills_dir = workspace_root.join(".claude").join("skills"); + if claude_skills_dir.is_dir() { + debug!("Discovering skills from {:?}", claude_skills_dir); + self.discover_from_directory( + &claude_skills_dir, + SkillSource::Project(workspace_root.clone()), + &mut skills, + ); + } + } + } + + let count = skills.len(); + *self.inner.skills.write().unwrap() = skills; + debug!("Discovered {} skills", count); + } + + /// Discovers skills from a single directory. + fn discover_from_directory( + &self, + dir: &Path, + source: SkillSource, + skills: &mut HashMap, + ) { + let entries = match std::fs::read_dir(dir) { + Ok(entries) => entries, + Err(e) => { + warn!("Failed to read skills directory {:?}: {}", dir, e); + return; + } + }; + + for entry in entries.flatten() { + let path = entry.path(); + if !path.is_dir() { + continue; + } + + let skill_file = path.join(SKILL_FILE_NAME); + if !skill_file.is_file() { + continue; + } + + let enabled = !self + .inner + .config + .disabled_skills + .contains(&path.file_name().unwrap_or_default().to_string_lossy().to_string()); + + match parse_skill_file(&skill_file, source.clone(), enabled) { + Ok(skill) => { + debug!( + "Discovered skill '{}' from {:?} (enabled: {})", + skill.metadata.name, skill_file, enabled + ); + skills.insert(skill.metadata.name.clone(), skill); + } + Err(e) => { + warn!("Failed to parse skill at {:?}: {}", skill_file, e); + } + } + } + } + + /// Returns metadata for all discovered skills. + pub fn get_all_metadata(&self) -> Vec { + self.inner + .skills + .read() + .unwrap() + .values() + .map(|s| s.metadata.clone()) + .collect() + } + + /// Returns metadata for enabled skills only. + pub fn get_enabled_metadata(&self) -> Vec { + self.inner + .skills + .read() + .unwrap() + .values() + .filter(|s| s.metadata.enabled) + .map(|s| s.metadata.clone()) + .collect() + } + + /// Gets a skill by name. + pub fn get_skill(&self, name: &str) -> Option { + self.inner.skills.read().unwrap().get(name).cloned() + } + + /// Loads full instructions for a skill. + pub fn load_instructions(&self, name: &str) -> Result { + self.inner + .skills + .read() + .unwrap() + .get(name) + .cloned() + .ok_or_else(|| anyhow::anyhow!("Skill '{}' not found", name)) + } + + /// Checks if a skill exists and is enabled. + pub fn is_enabled(&self, name: &str) -> bool { + self.inner + .skills + .read() + .unwrap() + .get(name) + .map(|s| s.metadata.enabled) + .unwrap_or(false) + } + + /// Returns the number of discovered skills. + pub fn count(&self) -> usize { + self.inner.skills.read().unwrap().len() + } + + /// Returns the number of enabled skills. + pub fn enabled_count(&self) -> usize { + self.inner + .skills + .read() + .unwrap() + .values() + .filter(|s| s.metadata.enabled) + .count() + } + +} + +impl Clone for SkillsManager { + fn clone(&self) -> Self { + Self { + inner: self.inner.clone(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use tempfile::TempDir; + + fn create_test_skill(dir: &Path, name: &str, description: &str) { + let skill_dir = dir.join(name); + fs::create_dir_all(&skill_dir).unwrap(); + + let content = format!( + r#"--- +name: {} +description: {} +--- + +# {} Skill + +Instructions for the skill. +"#, + name, description, name + ); + + fs::write(skill_dir.join("SKILL.md"), content).unwrap(); + } + + #[test] + fn test_discover_skills() { + let temp = TempDir::new().unwrap(); + let skills_dir = temp.path().join(".tycode").join("skills"); + fs::create_dir_all(&skills_dir).unwrap(); + + create_test_skill(&skills_dir, "test-skill", "A test skill"); + create_test_skill(&skills_dir, "another-skill", "Another skill"); + + let config = SkillsConfig::default(); + let manager = SkillsManager::discover(&[], temp.path(), &config); + + assert_eq!(manager.count(), 2); + assert!(manager.get_skill("test-skill").is_some()); + assert!(manager.get_skill("another-skill").is_some()); + } + + #[test] + fn test_project_overrides_user() { + let temp = TempDir::new().unwrap(); + + // Create user-level skill + let user_skills = temp.path().join(".tycode").join("skills"); + fs::create_dir_all(&user_skills).unwrap(); + create_test_skill(&user_skills, "my-skill", "User version"); + + // Create project-level skill with same name + let project_skills = temp.path().join("project").join(".tycode").join("skills"); + fs::create_dir_all(&project_skills).unwrap(); + create_test_skill(&project_skills, "my-skill", "Project version"); + + let config = SkillsConfig::default(); + let workspace_roots = vec![temp.path().join("project")]; + let manager = SkillsManager::discover(&workspace_roots, temp.path(), &config); + + // Should have only 1 skill (project overrides user) + assert_eq!(manager.count(), 1); + + let skill = manager.get_skill("my-skill").unwrap(); + assert_eq!(skill.metadata.description, "Project version"); + } + + #[test] + fn test_disabled_skills() { + let temp = TempDir::new().unwrap(); + let skills_dir = temp.path().join(".tycode").join("skills"); + fs::create_dir_all(&skills_dir).unwrap(); + + create_test_skill(&skills_dir, "enabled-skill", "Enabled"); + create_test_skill(&skills_dir, "disabled-skill", "Disabled"); + + let mut config = SkillsConfig::default(); + config.disabled_skills.insert("disabled-skill".to_string()); + + let manager = SkillsManager::discover(&[], temp.path(), &config); + + assert_eq!(manager.count(), 2); + assert_eq!(manager.enabled_count(), 1); + assert!(manager.is_enabled("enabled-skill")); + assert!(!manager.is_enabled("disabled-skill")); + } + + #[test] + fn test_skills_disabled_in_config() { + let temp = TempDir::new().unwrap(); + let skills_dir = temp.path().join(".tycode").join("skills"); + fs::create_dir_all(&skills_dir).unwrap(); + create_test_skill(&skills_dir, "test-skill", "Test"); + + let mut config = SkillsConfig::default(); + config.enabled = false; + + let manager = SkillsManager::discover(&[], temp.path(), &config); + + // Skills discovery is disabled, so no skills should be found + assert_eq!(manager.count(), 0); + } +} diff --git a/tycode-core/src/skills/mod.rs b/tycode-core/src/skills/mod.rs new file mode 100644 index 0000000..67ce14b --- /dev/null +++ b/tycode-core/src/skills/mod.rs @@ -0,0 +1,234 @@ +//! Skills system for extending agent capabilities. +//! +//! This module provides support for Claude Code Agent Skills - modular capabilities +//! that extend the agent's functionality. Skills are discovered from (in priority order): +//! +//! 1. `~/.claude/skills/` (user-level Claude Code compatibility) +//! 2. `~/.tycode/skills/` (user-level) +//! 3. `.claude/skills/` in each workspace (project-level Claude Code compatibility) +//! 4. `.tycode/skills/` in each workspace (project-level, highest priority) +//! +//! Later sources override earlier ones if the same skill name is found. +//! +//! Each skill is a directory containing a `SKILL.md` file with YAML frontmatter +//! defining the skill's name and description, followed by markdown instructions. + +pub mod context; +pub mod discovery; +pub mod parser; +pub mod prompt; +pub mod tool; +pub mod types; + +use std::path::PathBuf; +use std::sync::Arc; + +use anyhow::Result; +use serde_json::Value; + +use crate::context::ContextComponent; +use crate::module::{Module, SessionStateComponent}; +use crate::prompt::PromptComponent; +use crate::settings::config::SkillsConfig; +use crate::tools::r#trait::ToolExecutor; + +use context::{InvokedSkillsState, SkillsContextComponent}; +use discovery::SkillsManager; +use prompt::SkillsPromptComponent; +use tool::InvokeSkillTool; + +pub use context::InvokedSkill; +pub use discovery::SkillsManager as Manager; +pub use types::{SkillInstructions, SkillMetadata, SkillSource}; + +/// Module that provides skills functionality. +/// +/// SkillsModule bundles: +/// - `SkillsPromptComponent` - Lists available skills in system prompt +/// - `SkillsContextComponent` - Shows currently active/invoked skills +/// - `InvokeSkillTool` - Tool for loading skill instructions +pub struct SkillsModule { + manager: SkillsManager, + state: Arc, +} + +impl SkillsModule { + /// Creates a new SkillsModule by discovering skills from configured directories. + pub fn new(workspace_roots: &[PathBuf], home_dir: &std::path::Path, config: &SkillsConfig) -> Self { + let manager = SkillsManager::discover(workspace_roots, home_dir, config); + let state = Arc::new(InvokedSkillsState::new()); + Self { manager, state } + } + + /// Creates a SkillsModule with an existing manager (for testing). + pub fn with_manager(manager: SkillsManager) -> Self { + let state = Arc::new(InvokedSkillsState::new()); + Self { manager, state } + } + + /// Returns a reference to the skills manager. + pub fn manager(&self) -> &SkillsManager { + &self.manager + } + + /// Returns a reference to the invoked skills state. + pub fn state(&self) -> &Arc { + &self.state + } + + /// Reloads skills from all directories. + pub fn reload(&self) { + self.manager.reload(); + } + + /// Returns metadata for all discovered skills. + pub fn get_all_skills(&self) -> Vec { + self.manager.get_all_metadata() + } + + /// Returns metadata for enabled skills only. + pub fn get_enabled_skills(&self) -> Vec { + self.manager.get_enabled_metadata() + } + + /// Gets a skill by name. + pub fn get_skill(&self, name: &str) -> Option { + self.manager.get_skill(name) + } +} + +impl Module for SkillsModule { + fn prompt_components(&self) -> Vec> { + vec![Arc::new(SkillsPromptComponent::new(self.manager.clone()))] + } + + fn context_components(&self) -> Vec> { + vec![Arc::new(SkillsContextComponent::new(self.state.clone()))] + } + + fn tools(&self) -> Vec> { + vec![Arc::new(InvokeSkillTool::new( + self.manager.clone(), + self.state.clone(), + ))] + } + + fn session_state(&self) -> Option> { + Some(Arc::new(SkillsSessionState { + state: self.state.clone(), + })) + } +} + +/// Session state component for persisting invoked skills. +struct SkillsSessionState { + state: Arc, +} + +impl SessionStateComponent for SkillsSessionState { + fn key(&self) -> &str { + "skills" + } + + fn save(&self) -> Value { + let invoked = self.state.get_invoked(); + serde_json::json!({ + "invoked": invoked.iter().map(|s| { + serde_json::json!({ + "name": s.name, + "instructions": s.instructions, + }) + }).collect::>() + }) + } + + fn load(&self, state: Value) -> Result<()> { + self.state.clear(); + + if let Some(invoked) = state.get("invoked").and_then(|v| v.as_array()) { + for skill in invoked { + if let (Some(name), Some(instructions)) = ( + skill.get("name").and_then(|v| v.as_str()), + skill.get("instructions").and_then(|v| v.as_str()), + ) { + self.state.add_invoked(name.to_string(), instructions.to_string()); + } + } + } + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use tempfile::TempDir; + + fn create_test_skill(dir: &std::path::Path, name: &str, description: &str) { + let skill_dir = dir.join(name); + fs::create_dir_all(&skill_dir).unwrap(); + + let content = format!( + r#"--- +name: {} +description: {} +--- + +# {} Instructions + +Follow these steps. +"#, + name, description, name + ); + + fs::write(skill_dir.join("SKILL.md"), content).unwrap(); + } + + #[test] + fn test_skills_module_creation() { + let temp = TempDir::new().unwrap(); + let skills_dir = temp.path().join(".tycode").join("skills"); + fs::create_dir_all(&skills_dir).unwrap(); + + create_test_skill(&skills_dir, "test-skill", "A test skill"); + + let config = SkillsConfig::default(); + let module = SkillsModule::new(&[], temp.path(), &config); + + assert_eq!(module.get_all_skills().len(), 1); + } + + #[test] + fn test_module_provides_components() { + let temp = TempDir::new().unwrap(); + let config = SkillsConfig::default(); + let module = SkillsModule::new(&[], temp.path(), &config); + + assert_eq!(module.prompt_components().len(), 1); + assert_eq!(module.context_components().len(), 1); + assert_eq!(module.tools().len(), 1); + assert!(module.session_state().is_some()); + } + + #[test] + fn test_session_state_save_load() { + let state = Arc::new(InvokedSkillsState::new()); + state.add_invoked("test".to_string(), "instructions".to_string()); + + let session = SkillsSessionState { + state: state.clone(), + }; + + let saved = session.save(); + + // Clear and reload + state.clear(); + assert_eq!(state.get_invoked().len(), 0); + + session.load(saved).unwrap(); + assert_eq!(state.get_invoked().len(), 1); + assert!(state.is_invoked("test")); + } +} diff --git a/tycode-core/src/skills/parser.rs b/tycode-core/src/skills/parser.rs new file mode 100644 index 0000000..192a94c --- /dev/null +++ b/tycode-core/src/skills/parser.rs @@ -0,0 +1,247 @@ +use std::path::Path; + +use anyhow::{anyhow, Context, Result}; +use serde::Deserialize; + +use super::types::{SkillInstructions, SkillMetadata, SkillSource}; + +/// Raw frontmatter parsed from SKILL.md YAML header. +#[derive(Debug, Deserialize)] +struct RawFrontmatter { + name: String, + description: String, +} + +/// Parses a SKILL.md file and extracts metadata and instructions. +/// +/// The file format is: +/// ```markdown +/// --- +/// name: skill-name +/// description: What this skill does +/// --- +/// +/// # Skill Instructions +/// ... +/// ``` +pub fn parse_skill_file( + path: &Path, + source: SkillSource, + enabled: bool, +) -> Result { + let content = std::fs::read_to_string(path) + .with_context(|| format!("Failed to read skill file: {}", path.display()))?; + + parse_skill_content(&content, path, source, enabled) +} + +/// Parses skill content from a string. +pub fn parse_skill_content( + content: &str, + path: &Path, + source: SkillSource, + enabled: bool, +) -> Result { + let (frontmatter, instructions) = extract_frontmatter(content)?; + + let raw: RawFrontmatter = serde_yaml::from_str(&frontmatter) + .with_context(|| format!("Failed to parse YAML frontmatter in {}", path.display()))?; + + // Validate name format + if !SkillMetadata::is_valid_name(&raw.name) { + return Err(anyhow!( + "Invalid skill name '{}': must be lowercase letters, numbers, and hyphens only (max {} chars)", + raw.name, + super::types::MAX_SKILL_NAME_LENGTH + )); + } + + // Validate description + if !SkillMetadata::is_valid_description(&raw.description) { + return Err(anyhow!( + "Invalid skill description: must be non-empty and max {} chars", + super::types::MAX_SKILL_DESCRIPTION_LENGTH + )); + } + + let skill_dir = path + .parent() + .ok_or_else(|| anyhow!("Skill file has no parent directory"))?; + + // Discover reference files (*.md files other than SKILL.md) + let reference_files = discover_reference_files(skill_dir); + + // Discover scripts in scripts/ subdirectory + let scripts = discover_scripts(skill_dir); + + Ok(SkillInstructions { + metadata: SkillMetadata { + name: raw.name, + description: raw.description, + source, + path: path.to_path_buf(), + enabled, + }, + instructions, + reference_files, + scripts, + }) +} + +/// Extracts YAML frontmatter and body from a markdown file. +/// +/// Frontmatter is delimited by `---` at the start and end. +fn extract_frontmatter(content: &str) -> Result<(String, String)> { + let content = content.trim(); + + if !content.starts_with("---") { + return Err(anyhow!( + "SKILL.md must start with YAML frontmatter (---)" + )); + } + + // Find the closing --- + let rest = &content[3..]; + let end_pos = rest + .find("\n---") + .ok_or_else(|| anyhow!("SKILL.md frontmatter not closed (missing ---)"))?; + + let frontmatter = rest[..end_pos].trim().to_string(); + let body = rest[end_pos + 4..].trim().to_string(); + + if frontmatter.is_empty() { + return Err(anyhow!("SKILL.md frontmatter is empty")); + } + + Ok((frontmatter, body)) +} + +/// Discovers reference markdown files in the skill directory. +fn discover_reference_files(skill_dir: &Path) -> Vec { + let mut files = Vec::new(); + + if let Ok(entries) = std::fs::read_dir(skill_dir) { + for entry in entries.flatten() { + let path = entry.path(); + if path.is_file() { + if let Some(ext) = path.extension() { + if ext == "md" { + // Exclude SKILL.md itself + if let Some(name) = path.file_name() { + if name.to_string_lossy().to_uppercase() != "SKILL.MD" { + files.push(path); + } + } + } + } + } + } + } + + files.sort(); + files +} + +/// Discovers script files in the scripts/ subdirectory. +fn discover_scripts(skill_dir: &Path) -> Vec { + let scripts_dir = skill_dir.join("scripts"); + let mut files = Vec::new(); + + if scripts_dir.is_dir() { + if let Ok(entries) = std::fs::read_dir(&scripts_dir) { + for entry in entries.flatten() { + let path = entry.path(); + if path.is_file() { + files.push(path); + } + } + } + } + + files.sort(); + files +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_extract_frontmatter_valid() { + let content = r#"--- +name: test-skill +description: A test skill +--- + +# Instructions + +Some instructions here. +"#; + let (frontmatter, body) = extract_frontmatter(content).unwrap(); + assert!(frontmatter.contains("name: test-skill")); + assert!(frontmatter.contains("description: A test skill")); + assert!(body.contains("# Instructions")); + assert!(body.contains("Some instructions here.")); + } + + #[test] + fn test_extract_frontmatter_no_start() { + let content = "# No frontmatter\nJust content"; + assert!(extract_frontmatter(content).is_err()); + } + + #[test] + fn test_extract_frontmatter_no_end() { + let content = "---\nname: test\n# No closing delimiter"; + assert!(extract_frontmatter(content).is_err()); + } + + #[test] + fn test_parse_skill_content_valid() { + let content = r#"--- +name: my-skill +description: Does something useful when you ask +--- + +# My Skill + +Follow these instructions. +"#; + let path = Path::new("/test/skills/my-skill/SKILL.md"); + let result = parse_skill_content(content, path, SkillSource::User, true).unwrap(); + + assert_eq!(result.metadata.name, "my-skill"); + assert_eq!(result.metadata.description, "Does something useful when you ask"); + assert!(result.metadata.enabled); + assert!(result.instructions.contains("# My Skill")); + } + + #[test] + fn test_parse_skill_content_invalid_name() { + let content = r#"--- +name: Invalid_Name +description: Has invalid name +--- + +Instructions +"#; + let path = Path::new("/test/SKILL.md"); + let result = parse_skill_content(content, path, SkillSource::User, true); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("Invalid skill name")); + } + + #[test] + fn test_parse_skill_content_empty_description() { + let content = r#"--- +name: valid-name +description: "" +--- + +Instructions +"#; + let path = Path::new("/test/SKILL.md"); + let result = parse_skill_content(content, path, SkillSource::User, true); + assert!(result.is_err()); + } +} diff --git a/tycode-core/src/skills/prompt.rs b/tycode-core/src/skills/prompt.rs new file mode 100644 index 0000000..6a5b648 --- /dev/null +++ b/tycode-core/src/skills/prompt.rs @@ -0,0 +1,123 @@ +use crate::prompt::{PromptComponent, PromptComponentId}; +use crate::settings::config::Settings; + +use super::discovery::SkillsManager; + +/// Prompt component ID for skills. +pub const SKILLS_PROMPT_ID: PromptComponentId = PromptComponentId("skills"); + +/// Prompt component that lists available skills in the system prompt. +/// +/// This provides "Level 1" loading of skills - just metadata (name + description) +/// that helps the AI decide when to invoke skills. +pub struct SkillsPromptComponent { + manager: SkillsManager, +} + +impl SkillsPromptComponent { + pub fn new(manager: SkillsManager) -> Self { + Self { manager } + } +} + +impl PromptComponent for SkillsPromptComponent { + fn id(&self) -> PromptComponentId { + SKILLS_PROMPT_ID + } + + fn build_prompt_section(&self, _settings: &Settings) -> Option { + let skills = self.manager.get_enabled_metadata(); + + if skills.is_empty() { + return None; + } + + let mut output = String::new(); + output.push_str("## Available Skills\n\n"); + output.push_str("You have access to skills that provide specialized capabilities. "); + output.push_str("When a user's request matches a skill's description, "); + output.push_str("you MUST use the `invoke_skill` tool to load the skill instructions.\n\n"); + + output.push_str("| Skill | When to Use |\n"); + output.push_str("|-------|-------------|\n"); + + for skill in &skills { + // Truncate description for table display + let desc = if skill.description.len() > 80 { + format!("{}...", &skill.description[..77]) + } else { + skill.description.clone() + }; + output.push_str(&format!("| {} | {} |\n", skill.name, desc)); + } + + output.push_str("\n**CRITICAL**: You MUST call `invoke_skill` tool with the skill name to load instructions. "); + output.push_str("Do NOT attempt to read SKILL.md files directly via file tools or set_tracked_files. "); + output.push_str("The `invoke_skill` tool is the ONLY correct way to activate a skill.\n"); + + Some(output) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::settings::config::SkillsConfig; + use std::fs; + use tempfile::TempDir; + + fn create_test_skill(dir: &std::path::Path, name: &str, description: &str) { + let skill_dir = dir.join(name); + fs::create_dir_all(&skill_dir).unwrap(); + + let content = format!( + r#"--- +name: {} +description: {} +--- + +# {} Instructions +"#, + name, description, name + ); + + fs::write(skill_dir.join("SKILL.md"), content).unwrap(); + } + + #[test] + fn test_prompt_with_skills() { + let temp = TempDir::new().unwrap(); + let skills_dir = temp.path().join(".tycode").join("skills"); + fs::create_dir_all(&skills_dir).unwrap(); + + create_test_skill(&skills_dir, "commit", "When committing changes to git"); + create_test_skill(&skills_dir, "pdf", "When working with PDF documents"); + + let config = SkillsConfig::default(); + let manager = SkillsManager::discover(&[], temp.path(), &config); + let component = SkillsPromptComponent::new(manager); + + let settings = Settings::default(); + let prompt = component.build_prompt_section(&settings).unwrap(); + + assert!(prompt.contains("## Available Skills")); + assert!(prompt.contains("| commit |")); + assert!(prompt.contains("| pdf |")); + assert!(prompt.contains("invoke_skill")); + assert!(prompt.contains("CRITICAL")); + } + + #[test] + fn test_prompt_without_skills() { + let temp = TempDir::new().unwrap(); + + let config = SkillsConfig::default(); + let manager = SkillsManager::discover(&[], temp.path(), &config); + let component = SkillsPromptComponent::new(manager); + + let settings = Settings::default(); + let prompt = component.build_prompt_section(&settings); + + assert!(prompt.is_none()); + } +} diff --git a/tycode-core/src/skills/tool.rs b/tycode-core/src/skills/tool.rs new file mode 100644 index 0000000..3b0d194 --- /dev/null +++ b/tycode-core/src/skills/tool.rs @@ -0,0 +1,240 @@ +use std::sync::Arc; + +use anyhow::{bail, Result}; +use serde_json::{json, Value}; + +use crate::chat::events::{ToolExecutionResult, ToolRequest as ToolRequestEvent, ToolRequestType}; +use crate::tools::r#trait::{ + ContinuationPreference, ToolCallHandle, ToolCategory, ToolExecutor, ToolOutput, ToolRequest, +}; +use crate::tools::ToolName; + +use super::context::InvokedSkillsState; +use super::discovery::SkillsManager; + +/// Tool for invoking skills and loading their instructions. +pub struct InvokeSkillTool { + manager: SkillsManager, + state: Arc, +} + +impl InvokeSkillTool { + pub fn new(manager: SkillsManager, state: Arc) -> Self { + Self { manager, state } + } + + pub fn tool_name() -> ToolName { + ToolName::new("invoke_skill") + } +} + +struct InvokeSkillHandle { + skill_name: String, + tool_use_id: String, + manager: SkillsManager, + state: Arc, +} + +#[async_trait::async_trait(?Send)] +impl ToolCallHandle for InvokeSkillHandle { + fn tool_request(&self) -> ToolRequestEvent { + ToolRequestEvent { + tool_call_id: self.tool_use_id.clone(), + tool_name: "invoke_skill".to_string(), + tool_type: ToolRequestType::Other { + args: json!({ "skill_name": self.skill_name }), + }, + } + } + + async fn execute(self: Box) -> ToolOutput { + // Load the skill instructions + match self.manager.load_instructions(&self.skill_name) { + Ok(skill) => { + // Record that this skill has been invoked + self.state + .add_invoked(skill.metadata.name.clone(), skill.instructions.clone()); + + // Build the response + let mut response = format!( + "Skill '{}' loaded successfully.\n\n## Instructions\n\n{}", + skill.metadata.name, skill.instructions + ); + + // Include reference files if any + if !skill.reference_files.is_empty() { + response.push_str("\n\n## Reference Files\n\n"); + response.push_str( + "The following reference files are available. Use the read_file tool to access them:\n", + ); + for file in &skill.reference_files { + response.push_str(&format!("- {}\n", file.display())); + } + } + + // Include scripts if any + if !skill.scripts.is_empty() { + response.push_str("\n\n## Scripts\n\n"); + response + .push_str("The following scripts are available for use with this skill:\n"); + for script in &skill.scripts { + response.push_str(&format!("- {}\n", script.display())); + } + } + + ToolOutput::Result { + content: response, + is_error: false, + continuation: ContinuationPreference::Continue, + ui_result: ToolExecutionResult::Other { + result: json!({ + "skill_name": skill.metadata.name, + "source": format!("{}", skill.metadata.source), + }), + }, + } + } + Err(e) => ToolOutput::Result { + content: format!("Failed to load skill '{}': {}", self.skill_name, e), + is_error: true, + continuation: ContinuationPreference::Continue, + ui_result: ToolExecutionResult::Error { + short_message: format!("Skill '{}' not found", self.skill_name), + detailed_message: e.to_string(), + }, + }, + } + } +} + +#[async_trait::async_trait(?Send)] +impl ToolExecutor for InvokeSkillTool { + fn name(&self) -> &str { + "invoke_skill" + } + + fn description(&self) -> &str { + "Load and activate a skill's instructions. Use this when a user's request matches \ + a skill's description from the Available Skills list. The skill will provide \ + detailed instructions for how to proceed with the task." + } + + fn input_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "skill_name": { + "type": "string", + "description": "The name of the skill to invoke (from the Available Skills list)" + } + }, + "required": ["skill_name"] + }) + } + + fn category(&self) -> ToolCategory { + ToolCategory::Meta + } + + async fn process(&self, request: &ToolRequest) -> Result> { + let Some(skill_name) = request.arguments["skill_name"].as_str() else { + bail!("Missing required argument \"skill_name\""); + }; + + // Check if skill exists and is enabled + if !self.manager.is_enabled(skill_name) { + if self.manager.get_skill(skill_name).is_some() { + bail!("Skill '{}' is disabled", skill_name); + } else { + bail!( + "Skill '{}' not found. Use /skills to list available skills.", + skill_name + ); + } + } + + Ok(Box::new(InvokeSkillHandle { + skill_name: skill_name.to_string(), + tool_use_id: request.tool_use_id.clone(), + manager: self.manager.clone(), + state: self.state.clone(), + })) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::settings::config::SkillsConfig; + use std::fs; + use tempfile::TempDir; + + fn create_test_skill(dir: &std::path::Path, name: &str, description: &str, instructions: &str) { + let skill_dir = dir.join(name); + fs::create_dir_all(&skill_dir).unwrap(); + + let content = format!( + r#"--- +name: {} +description: {} +--- + +{} +"#, + name, description, instructions + ); + + fs::write(skill_dir.join("SKILL.md"), content).unwrap(); + } + + #[tokio::test] + async fn test_invoke_skill_success() { + let temp = TempDir::new().unwrap(); + let skills_dir = temp.path().join(".tycode").join("skills"); + fs::create_dir_all(&skills_dir).unwrap(); + + create_test_skill( + &skills_dir, + "test-skill", + "A test skill", + "# Test Instructions\n\nFollow these steps.", + ); + + let config = SkillsConfig::default(); + let manager = SkillsManager::discover(&[], temp.path(), &config); + let state = Arc::new(InvokedSkillsState::new()); + let tool = InvokeSkillTool::new(manager, state.clone()); + + let request = ToolRequest::new(json!({"skill_name": "test-skill"}), "test-id".to_string()); + + let handle = tool.process(&request).await.unwrap(); + let output = handle.execute().await; + + if let ToolOutput::Result { + content, is_error, .. + } = output + { + assert!(!is_error); + assert!(content.contains("Test Instructions")); + assert!(state.is_invoked("test-skill")); + } else { + panic!("Expected ToolOutput::Result"); + } + } + + #[tokio::test] + async fn test_invoke_skill_not_found() { + let temp = TempDir::new().unwrap(); + + let config = SkillsConfig::default(); + let manager = SkillsManager::discover(&[], temp.path(), &config); + let state = Arc::new(InvokedSkillsState::new()); + let tool = InvokeSkillTool::new(manager, state); + + let request = + ToolRequest::new(json!({"skill_name": "nonexistent"}), "test-id".to_string()); + + let result = tool.process(&request).await; + assert!(result.is_err()); + } +} diff --git a/tycode-core/src/skills/types.rs b/tycode-core/src/skills/types.rs new file mode 100644 index 0000000..7e89e40 --- /dev/null +++ b/tycode-core/src/skills/types.rs @@ -0,0 +1,128 @@ +use std::path::PathBuf; + +use serde::{Deserialize, Serialize}; + +/// Maximum length for skill names (matches Claude Code spec). +pub const MAX_SKILL_NAME_LENGTH: usize = 64; + +/// Maximum length for skill descriptions (matches Claude Code spec). +pub const MAX_SKILL_DESCRIPTION_LENGTH: usize = 1024; + +/// Where a skill was discovered from. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum SkillSource { + /// Project-level skill from .tycode/skills/ or .claude/skills/ in a workspace. + /// The PathBuf contains the workspace root path. + Project(PathBuf), + /// User-level skill from ~/.tycode/skills/ + User, + /// User-level Claude Code compatibility from ~/.claude/skills/ + ClaudeCode, +} + +impl std::fmt::Display for SkillSource { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + SkillSource::Project(path) => write!(f, "project ({})", path.display()), + SkillSource::User => write!(f, "user"), + SkillSource::ClaudeCode => write!(f, "claude-code"), + } + } +} + +/// Metadata parsed from a skill's YAML frontmatter. +/// +/// This is the "Level 1" content that is always loaded at startup +/// and included in the system prompt (~100 tokens per skill). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SkillMetadata { + /// Unique identifier for the skill. + /// Must be lowercase letters, numbers, and hyphens only. + /// Maximum 64 characters. + pub name: String, + + /// Description of what the skill does and when to use it. + /// This helps the AI decide when to invoke the skill. + /// Maximum 1024 characters. + pub description: String, + + /// Where the skill was discovered from. + pub source: SkillSource, + + /// Absolute path to the skill's SKILL.md file. + pub path: PathBuf, + + /// Whether the skill is enabled (can be disabled in settings). + pub enabled: bool, +} + +impl SkillMetadata { + /// Validates the skill name format. + /// Names must be lowercase letters, numbers, and hyphens only. + pub fn is_valid_name(name: &str) -> bool { + !name.is_empty() + && name.len() <= MAX_SKILL_NAME_LENGTH + && name + .chars() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') + && !name.starts_with('-') + && !name.ends_with('-') + } + + /// Validates the skill description. + pub fn is_valid_description(description: &str) -> bool { + !description.is_empty() && description.len() <= MAX_SKILL_DESCRIPTION_LENGTH + } +} + +/// Full skill instructions loaded on demand. +/// +/// This is the "Level 2" content that is loaded when a skill is invoked. +/// Contains the full markdown instructions from SKILL.md. +#[derive(Debug, Clone)] +pub struct SkillInstructions { + /// The skill's metadata. + pub metadata: SkillMetadata, + + /// Full markdown instructions from SKILL.md (after frontmatter). + pub instructions: String, + + /// Paths to additional reference files (REFERENCE.md, etc.). + pub reference_files: Vec, + + /// Paths to script files in the scripts/ directory. + pub scripts: Vec, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_valid_skill_names() { + assert!(SkillMetadata::is_valid_name("commit")); + assert!(SkillMetadata::is_valid_name("pdf-processing")); + assert!(SkillMetadata::is_valid_name("skill123")); + assert!(SkillMetadata::is_valid_name("my-skill-2")); + } + + #[test] + fn test_invalid_skill_names() { + assert!(!SkillMetadata::is_valid_name("")); // empty + assert!(!SkillMetadata::is_valid_name("My-Skill")); // uppercase + assert!(!SkillMetadata::is_valid_name("skill_name")); // underscore + assert!(!SkillMetadata::is_valid_name("-skill")); // starts with hyphen + assert!(!SkillMetadata::is_valid_name("skill-")); // ends with hyphen + assert!(!SkillMetadata::is_valid_name(&"a".repeat(65))); // too long + } + + #[test] + fn test_skill_source_display() { + assert_eq!(SkillSource::User.to_string(), "user"); + assert_eq!(SkillSource::ClaudeCode.to_string(), "claude-code"); + assert_eq!( + SkillSource::Project(PathBuf::from("/project")).to_string(), + "project (/project)" + ); + } +} diff --git a/tycode-subprocess/Cargo.toml b/tycode-subprocess/Cargo.toml index b142ce0..e6481c7 100644 --- a/tycode-subprocess/Cargo.toml +++ b/tycode-subprocess/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "tycode-subprocess" -version = "0.3.6" +version = "0.4.0" edition = "2021" authors = ["tigy"] description = "Subprocess logic for TyCode"