diff --git a/README.md b/README.md index 9e26dab..76cd08a 100644 --- a/README.md +++ b/README.md @@ -109,6 +109,43 @@ These commands give you fine-grained control over context management, allowing y - **Code Search**: Embedded tree-sitter for syntax-aware code search (Rust, Python, JavaScript, TypeScript, Go, Java, C, C++) - see [Code Search Guide](docs/CODE_SEARCH.md) - **Final Output**: Formatted result presentation +### Agent Skills + +g3 supports the [Agent Skills](https://agentskills.io) specification - an open format for portable skill packages that give the agent new capabilities. + +**Skill Locations** (in priority order, later overrides earlier): +1. Global: `~/.g3/skills/` +2. Extra paths from config +3. Workspace: `.g3/skills/` (highest priority) + +**SKILL.md Format**: +```yaml +--- +name: pdf-processing # Required: 1-64 chars, lowercase + hyphens +description: Extract text... # Required: 1-1024 chars, when to use +license: Apache-2.0 # Optional +compatibility: Requires git # Optional: environment requirements +--- + +# PDF Processing + +Detailed instructions for the agent... +``` + +**Configuration** (in `g3.toml`): +```toml +[skills] +enabled = true # Default: true +extra_paths = ["/path/to/skills"] # Additional skill directories +``` + +At startup, g3 scans skill directories and injects a summary into the system prompt. When the agent needs a skill, it reads the full `SKILL.md` using the `read_file` tool. + +Each skill adds ~50-100 tokens to context (name + description + path). Skills can include: +- `scripts/` - Executable code (Python, Bash, etc.) +- `references/` - Additional documentation +- `assets/` - Templates, data files + ### Provider Flexibility - Support for multiple LLM providers through a unified interface - Hot-swappable providers without code changes diff --git a/analysis/memory.md b/analysis/memory.md index b859233..4a76904 100644 --- a/analysis/memory.md +++ b/analysis/memory.md @@ -1,5 +1,5 @@ # Workspace Memory -> Updated: 2026-02-02T03:53:06Z | Size: 16.9k chars +> Updated: 2026-02-04T01:34:05Z | Size: 18.9k chars ### Remember Tool Wiring - `crates/g3-core/src/tools/memory.rs` [0..5000] - `execute_remember()`, `get_memory_path()`, `merge_memory()` @@ -314,4 +314,49 @@ Verifies evidence in completed plan items deterministically. - Code location file only: `src/foo.rs` - Test reference: `tests/foo.rs::test_bar` -**Integration:** Called from `execute_plan_write()` when plan is complete and approved (line 828-833) \ No newline at end of file +**Integration:** Called from `execute_plan_write()` when plan is complete and approved (line 828-833) + +### Agent Skills Support +Implements the Agent Skills specification (https://agentskills.io) for portable skill packages. + +- `crates/g3-core/src/skills/mod.rs` [0..42] - module exports: `Skill`, `discover_skills`, `generate_skills_prompt` +- `crates/g3-core/src/skills/parser.rs` [0..363] + - `Skill` [11..30] - parsed skill struct with name, description, metadata, body, path + - `Skill::parse()` [45..100] - parses SKILL.md content with YAML frontmatter + - `Skill::from_file()` [95..105] - loads and parses from disk + - `split_frontmatter()` [107..130] - extracts YAML between `---` delimiters + - `validate_name()` [133..175] - validates 1-64 chars, lowercase+hyphens +- `crates/g3-core/src/skills/discovery.rs` [0..268] + - `discover_skills()` [28..65] - scans global, extra, workspace dirs in priority order + - `load_skills_from_dir()` [68..100] - loads SKILL.md from subdirectories + - `expand_tilde()` [120..125] - uses shellexpand for path expansion +- `crates/g3-core/src/skills/prompt.rs` [0..140] + - `generate_skills_prompt()` [12..40] - generates `` XML block + - `escape_xml()` [42..48] - escapes special XML characters +- `crates/g3-config/src/lib.rs` + - `SkillsConfig` [180..200] - enabled flag, extra_paths vector + - Config.skills field [13..14] +- `crates/g3-cli/src/project_files.rs` + - `discover_and_format_skills()` [180..210] - discovers skills and generates prompt section + - `combine_project_content()` [87..110] - now includes skills_content parameter + +**Skill Locations** (priority order): +1. `~/.g3/skills/` (global) +2. Config extra_paths +3. `.g3/skills/` (workspace, highest priority) + +**SKILL.md Format**: +```yaml +--- +name: skill-name # Required: 1-64 chars, lowercase + hyphens +description: What it does # Required: 1-1024 chars +license: Apache-2.0 # Optional +compatibility: Requires X # Optional: max 500 chars +metadata: # Optional: arbitrary key-value + author: org +allowed-tools: Bash Read # Optional/experimental +--- + +# Skill Title +Detailed instructions... +``` \ No newline at end of file diff --git a/crates/g3-cli/src/agent_mode.rs b/crates/g3-cli/src/agent_mode.rs index 8160ede..d82ad9a 100644 --- a/crates/g3-cli/src/agent_mode.rs +++ b/crates/g3-cli/src/agent_mode.rs @@ -6,7 +6,7 @@ use tracing::debug; use g3_core::ui_writer::UiWriter; use g3_core::Agent; -use crate::project_files::{combine_project_content, read_agents_config, read_include_prompt, read_workspace_memory}; +use crate::project_files::{combine_project_content, discover_and_format_skills, read_agents_config, read_include_prompt, read_workspace_memory}; use crate::display::{LoadedContent, print_loaded_status, print_workspace_path}; use crate::language_prompts::{get_language_prompts_for_workspace, get_agent_language_prompts_for_workspace_with_langs}; use crate::simple_output::SimpleOutput; @@ -154,12 +154,16 @@ pub async fn run_agent_mode( system_prompt }; + // Discover skills from configured paths + let (_skills, skills_content) = discover_and_format_skills(&workspace_dir, &config.skills); + // Combine all content for the agent's context let combined_content = combine_project_content( agents_content_opt, memory_content_opt, language_content, include_prompt, + skills_content, &workspace_dir, ); diff --git a/crates/g3-cli/src/lib.rs b/crates/g3-cli/src/lib.rs index 846cfcb..f751e2c 100644 --- a/crates/g3-cli/src/lib.rs +++ b/crates/g3-cli/src/lib.rs @@ -40,7 +40,7 @@ use accumulative::run_accumulative_mode; use agent_mode::run_agent_mode; use autonomous::run_autonomous; use interactive::run_interactive; -use project_files::{combine_project_content, read_agents_config, read_include_prompt, read_workspace_memory}; +use project_files::{combine_project_content, discover_and_format_skills, read_agents_config, read_include_prompt, read_workspace_memory}; use simple_output::SimpleOutput; use ui_writer_impl::ConsoleUiWriter; use g3_core::ui_writer::UiWriter; @@ -117,8 +117,13 @@ pub async fn run() -> Result<()> { // Load configuration with CLI overrides let config = load_config_with_cli_overrides(&cli)?; + // Discover skills from configured paths + let (_skills, skills_content) = discover_and_format_skills(&workspace_dir, &config.skills); + // Combine AGENTS.md and memory content - let combined_content = combine_project_content(agents_content, memory_content, language_content, include_prompt, &workspace_dir); + let combined_content = combine_project_content( + agents_content, memory_content, language_content, include_prompt, skills_content, &workspace_dir + ); run_console_mode(cli, config, project, combined_content, workspace_dir).await } diff --git a/crates/g3-cli/src/project_files.rs b/crates/g3-cli/src/project_files.rs index 6da3ab1..da7e4d2 100644 --- a/crates/g3-cli/src/project_files.rs +++ b/crates/g3-cli/src/project_files.rs @@ -3,9 +3,12 @@ //! Reads AGENTS.md and workspace memory files from the workspace. use std::path::Path; +use std::path::PathBuf; use tracing::error; use crate::template::process_template; +use g3_core::{discover_skills, generate_skills_prompt, Skill}; +use g3_config::SkillsConfig; /// Read AGENTS.md configuration from the workspace directory. /// Returns formatted content with emoji prefix, or None if not found. @@ -86,15 +89,16 @@ pub fn combine_project_content( memory_content: Option, language_content: Option, include_prompt: Option, + skills_content: Option, workspace_dir: &Path, ) -> Option { // Always include working directory to prevent LLM from hallucinating paths let cwd_info = format!("📂 Working Directory: {}", workspace_dir.display()); - // Order: cwd → agents → language → include_prompt → memory + // Order: cwd → agents → language → include_prompt → skills → memory // Include prompt comes BEFORE memory so memory is always last (most recent context) let parts: Vec = [ - Some(cwd_info), agents_content, language_content, include_prompt, memory_content + Some(cwd_info), agents_content, language_content, include_prompt, skills_content, memory_content ] .into_iter() .flatten() @@ -171,6 +175,38 @@ fn truncate_for_display(s: &str, max_len: usize) -> String { } } +/// Discover skills from configured paths and generate the skills prompt. +/// +/// Returns the skills prompt section if any skills are found, None otherwise. +/// Skills are discovered from: +/// 1. Global: ~/.g3/skills/ +/// 2. Extra paths from config +/// 3. Workspace: .g3/skills/ (highest priority) +pub fn discover_and_format_skills( + workspace_dir: &Path, + skills_config: &SkillsConfig, +) -> (Vec, Option) { + if !skills_config.enabled { + return (Vec::new(), None); + } + + // Convert extra_paths from config to PathBuf + let extra_paths: Vec = skills_config + .extra_paths + .iter() + .map(|p| PathBuf::from(p)) + .collect(); + + let skills = discover_skills(Some(workspace_dir), &extra_paths); + + if skills.is_empty() { + return (Vec::new(), None); + } + + let prompt = generate_skills_prompt(&skills); + (skills, Some(prompt)) +} + #[cfg(test)] mod tests { use super::*; @@ -219,6 +255,7 @@ mod tests { Some("memory".to_string()), Some("language".to_string()), None, // include_prompt + None, // skills_content &workspace, ); assert!(result.is_some()); @@ -232,7 +269,7 @@ mod tests { #[test] fn test_combine_project_content_partial() { let workspace = std::path::PathBuf::from("/test/workspace"); - let result = combine_project_content(None, Some("memory".to_string()), None, None, &workspace); + let result = combine_project_content(None, Some("memory".to_string()), None, None, None, &workspace); assert!(result.is_some()); let content = result.unwrap(); assert!(content.contains("📂 Working Directory: /test/workspace")); @@ -242,7 +279,7 @@ mod tests { #[test] fn test_combine_project_content_all_none() { let workspace = std::path::PathBuf::from("/test/workspace"); - let result = combine_project_content(None, None, None, None, &workspace); + let result = combine_project_content(None, None, None, None, None, &workspace); // Now always returns Some because we always include the working directory assert!(result.is_some()); assert!(result.unwrap().contains("📂 Working Directory: /test/workspace")); @@ -256,6 +293,7 @@ mod tests { Some("memory".to_string()), Some("language".to_string()), Some("include_prompt".to_string()), + None, // skills_content &workspace, ); assert!(result.is_some()); @@ -272,6 +310,7 @@ mod tests { Some("MEMORY_CONTENT".to_string()), Some("LANGUAGE_CONTENT".to_string()), Some("INCLUDE_PROMPT_CONTENT".to_string()), + None, // skills_content &workspace, ); let content = result.unwrap(); @@ -297,6 +336,7 @@ mod tests { Some("MEMORY".to_string()), Some("LANGUAGE".to_string()), None, // no include_prompt + None, // skills_content &workspace, ); let content = result.unwrap(); diff --git a/crates/g3-config/src/lib.rs b/crates/g3-config/src/lib.rs index f7137cf..77cc4aa 100644 --- a/crates/g3-config/src/lib.rs +++ b/crates/g3-config/src/lib.rs @@ -13,6 +13,8 @@ pub struct Config { pub computer_control: ComputerControlConfig, #[serde(default)] pub webdriver: WebDriverConfig, + #[serde(default)] + pub skills: SkillsConfig, } /// Provider configuration with named configs per provider type @@ -193,6 +195,26 @@ pub struct WebDriverConfig { pub browser: WebDriverBrowser, } +/// Skills configuration +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SkillsConfig { + /// Whether skills are enabled (default: true) + #[serde(default = "default_true")] + pub enabled: bool, + /// Additional paths to search for skills (beyond ~/.g3/skills and .g3/skills) + #[serde(default)] + pub extra_paths: Vec, +} + +impl Default for SkillsConfig { + fn default() -> Self { + Self { + enabled: true, + extra_paths: Vec::new(), + } + } +} + impl Default for AgentConfig { fn default() -> Self { Self { @@ -257,6 +279,7 @@ impl Default for Config { }, computer_control: ComputerControlConfig::default(), webdriver: WebDriverConfig::default(), + skills: SkillsConfig::default(), } } } diff --git a/crates/g3-core/src/lib.rs b/crates/g3-core/src/lib.rs index bec5498..7802f7b 100644 --- a/crates/g3-core/src/lib.rs +++ b/crates/g3-core/src/lib.rs @@ -23,6 +23,7 @@ pub mod tools; pub mod ui_writer; pub mod utils; pub mod webdriver_session; +pub mod skills; pub use feedback_extraction::{ extract_coach_feedback, ExtractedFeedback, FeedbackExtractionConfig, FeedbackSource, @@ -42,7 +43,13 @@ pub use context_window::{ContextWindow, ThinResult, ThinScope}; pub use pending_research::{PendingResearchManager, ResearchCompletionNotification, ResearchStatus}; // Export agent prompt generation for CLI use -pub use prompts::get_agent_system_prompt; +pub use prompts::{ + get_agent_system_prompt, get_agent_system_prompt_with_skills, + get_system_prompt_for_native_with_skills, get_system_prompt_for_non_native_with_skills, +}; + +// Export skills module +pub use skills::{Skill, discover_skills, generate_skills_prompt}; #[cfg(test)] mod task_result_comprehensive_tests; diff --git a/crates/g3-core/src/prompts.rs b/crates/g3-core/src/prompts.rs index f066223..6559728 100644 --- a/crates/g3-core/src/prompts.rs +++ b/crates/g3-core/src/prompts.rs @@ -300,30 +300,58 @@ write_file(\"helper.rs\", \"...\") // COMPOSED PROMPTS // ============================================================================ +use crate::skills::{Skill, generate_skills_prompt}; + /// System prompt for providers with native tool calling (Anthropic, OpenAI, etc.) pub fn get_system_prompt_for_native() -> String { + get_system_prompt_for_native_with_skills(&[]) +} + +/// System prompt for providers with native tool calling, with skills support. +pub fn get_system_prompt_for_native_with_skills(skills: &[Skill]) -> String { + let skills_section = generate_skills_prompt(skills); + let skills_part = if skills_section.is_empty() { + String::new() + } else { + format!("\n\n{}", skills_section) + }; + format!( - "{}\n\n{}\n\n{}\n\n{}\n\n{}\n\n{}", + "{}\n\n{}\n\n{}\n\n{}\n\n{}\n\n{}{}", SHARED_INTRO, SHARED_PLAN_SECTION, SHARED_TEMPORARY_FILES, SHARED_WEB_RESEARCH, SHARED_WORKSPACE_MEMORY, - SHARED_RESPONSE_GUIDELINES + SHARED_RESPONSE_GUIDELINES, + skills_part ) } /// System prompt for providers without native tool calling (embedded models) pub fn get_system_prompt_for_non_native() -> String { + get_system_prompt_for_non_native_with_skills(&[]) +} + +/// System prompt for providers without native tool calling, with skills support. +pub fn get_system_prompt_for_non_native_with_skills(skills: &[Skill]) -> String { + let skills_section = generate_skills_prompt(skills); + let skills_part = if skills_section.is_empty() { + String::new() + } else { + format!("\n\n{}", skills_section) + }; + format!( - "{}\n\n{}\n\n{}\n\n{}\n\n{}\n\n{}\n\n{}", + "{}\n\n{}\n\n{}\n\n{}\n\n{}\n\n{}\n\n{}{}", SHARED_INTRO, NON_NATIVE_TOOL_FORMAT, NON_NATIVE_INSTRUCTIONS, SHARED_PLAN_SECTION, SHARED_WEB_RESEARCH, SHARED_WORKSPACE_MEMORY, - SHARED_RESPONSE_GUIDELINES + SHARED_RESPONSE_GUIDELINES, + skills_part ) } @@ -336,9 +364,17 @@ const G3_IDENTITY_LINE: &str = "You are G3, an AI programming agent of the same /// The agent_prompt replaces only the G3 identity line at the start of the prompt. /// Everything else (tool instructions, coding guidelines, etc.) is preserved. pub fn get_agent_system_prompt(agent_prompt: &str, allow_multiple_tool_calls: bool) -> String { + get_agent_system_prompt_with_skills(agent_prompt, allow_multiple_tool_calls, &[]) +} + +/// Generate a system prompt for agent mode with skills support. +/// +/// The agent_prompt replaces only the G3 identity line at the start of the prompt. +/// Everything else (tool instructions, coding guidelines, skills, etc.) is preserved. +pub fn get_agent_system_prompt_with_skills(agent_prompt: &str, allow_multiple_tool_calls: bool, skills: &[Skill]) -> String { // Get the full system prompt (always allows multiple tool calls now) let _ = allow_multiple_tool_calls; // Parameter kept for API compatibility but ignored - let full_prompt = get_system_prompt_for_native(); + let full_prompt = get_system_prompt_for_native_with_skills(skills); // Replace only the G3 identity line with the custom agent prompt full_prompt.replace(G3_IDENTITY_LINE, agent_prompt.trim()) @@ -419,4 +455,50 @@ mod tests { assert!(native.contains("# Web Research")); assert!(non_native.contains("# Web Research")); } + + #[test] + fn test_native_prompt_without_skills() { + let prompt = get_system_prompt_for_native_with_skills(&[]); + assert!(!prompt.contains("")); + assert!(!prompt.contains("# Available Skills")); + } + + #[test] + fn test_native_prompt_with_skills() { + let skills = vec![Skill { + name: "test-skill".to_string(), + description: "A test skill for unit testing".to_string(), + license: None, + compatibility: None, + metadata: None, + allowed_tools: None, + body: String::new(), + path: "/path/to/test-skill/SKILL.md".to_string(), + }]; + + let prompt = get_system_prompt_for_native_with_skills(&skills); + assert!(prompt.contains("# Available Skills")); + assert!(prompt.contains("")); + assert!(prompt.contains("test-skill")); + assert!(prompt.contains("A test skill for unit testing")); + assert!(prompt.contains("/path/to/test-skill/SKILL.md")); + } + + #[test] + fn test_agent_prompt_with_skills() { + let skills = vec![Skill { + name: "agent-skill".to_string(), + description: "Skill for agent mode".to_string(), + license: None, + compatibility: None, + metadata: None, + allowed_tools: None, + body: String::new(), + path: "/path/to/SKILL.md".to_string(), + }]; + + let prompt = get_agent_system_prompt_with_skills("Custom agent", true, &skills); + assert!(prompt.contains("Custom agent")); + assert!(prompt.contains("agent-skill")); + } } diff --git a/crates/g3-core/src/skills/discovery.rs b/crates/g3-core/src/skills/discovery.rs new file mode 100644 index 0000000..02fd42b --- /dev/null +++ b/crates/g3-core/src/skills/discovery.rs @@ -0,0 +1,269 @@ +//! Skill discovery - scans directories for SKILL.md files. +//! +//! Discovers skills from: +//! - Global: ~/.g3/skills/ +//! - Workspace: .g3/skills/ +//! +//! Workspace skills override global skills with the same name. + +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use tracing::{debug, warn}; + +use super::parser::Skill; + +/// Default global skills directory +const GLOBAL_SKILLS_DIR: &str = "~/.g3/skills"; + +/// Default workspace skills directory (relative to workspace root) +const WORKSPACE_SKILLS_DIR: &str = ".g3/skills"; + +/// Discover all available skills from configured paths. +/// +/// Skills are loaded from: +/// 1. Global directory (~/.g3/skills/) +/// 2. Workspace directory (.g3/skills/) +/// +/// Workspace skills override global skills with the same name. +/// Additional paths can be provided via `extra_paths`. +pub fn discover_skills( + workspace_dir: Option<&Path>, + extra_paths: &[PathBuf], +) -> Vec { + let mut skills_by_name: HashMap = HashMap::new(); + + // 1. Load global skills first (lowest priority) + let global_dir = expand_tilde(GLOBAL_SKILLS_DIR); + if global_dir.exists() { + debug!("Scanning global skills directory: {}", global_dir.display()); + load_skills_from_dir(&global_dir, &mut skills_by_name); + } + + // 2. Load from extra paths (medium priority) + for path in extra_paths { + let expanded = if path.starts_with("~") { + expand_tilde(&path.to_string_lossy()) + } else { + path.clone() + }; + if expanded.exists() { + debug!("Scanning extra skills directory: {}", expanded.display()); + load_skills_from_dir(&expanded, &mut skills_by_name); + } + } + + // 3. Load workspace skills last (highest priority - overrides others) + if let Some(workspace) = workspace_dir { + let workspace_skills = workspace.join(WORKSPACE_SKILLS_DIR); + if workspace_skills.exists() { + debug!("Scanning workspace skills directory: {}", workspace_skills.display()); + load_skills_from_dir(&workspace_skills, &mut skills_by_name); + } + } + + // Convert to sorted vector for deterministic ordering + let mut skills: Vec = skills_by_name.into_values().collect(); + skills.sort_by(|a, b| a.name.cmp(&b.name)); + + debug!("Discovered {} skills", skills.len()); + skills +} + +/// Load skills from a directory into the map. +/// Each subdirectory should contain a SKILL.md file. +fn load_skills_from_dir(dir: &Path, skills: &mut HashMap) { + let entries = match std::fs::read_dir(dir) { + Ok(entries) => entries, + Err(e) => { + warn!("Failed to read skills directory {}: {}", dir.display(), e); + return; + } + }; + + for entry in entries.filter_map(|e| e.ok()) { + let path = entry.path(); + + // Skip non-directories + if !path.is_dir() { + continue; + } + + // Look for SKILL.md in this directory + let skill_file = path.join("SKILL.md"); + if !skill_file.exists() { + // Also check for lowercase variant + let skill_file_lower = path.join("skill.md"); + if skill_file_lower.exists() { + load_skill_file(&skill_file_lower, skills); + } + continue; + } + + load_skill_file(&skill_file, skills); + } +} + +/// Load a single skill file and add to the map. +fn load_skill_file(path: &Path, skills: &mut HashMap) { + match Skill::from_file(path) { + Ok(skill) => { + let name = skill.name.clone(); + if skills.contains_key(&name) { + debug!("Skill '{}' overridden by {}", name, path.display()); + } + skills.insert(name, skill); + } + Err(e) => { + warn!("Failed to parse skill {}: {}", path.display(), e); + } + } +} + +/// Expand tilde in path to home directory. +fn expand_tilde(path: &str) -> PathBuf { + let expanded = shellexpand::tilde(path); + PathBuf::from(expanded.as_ref()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use tempfile::TempDir; + + fn create_skill_dir(parent: &Path, name: &str, description: &str) -> PathBuf { + let skill_dir = parent.join(name); + fs::create_dir_all(&skill_dir).unwrap(); + + let content = format!( + "---\nname: {}\ndescription: {}\n---\n\n# {}\n\nSkill body.", + name, description, name + ); + fs::write(skill_dir.join("SKILL.md"), content).unwrap(); + + skill_dir + } + + #[test] + fn test_discover_from_workspace() { + let temp = TempDir::new().unwrap(); + let workspace = temp.path(); + + // Create workspace skills directory + let skills_dir = workspace.join(".g3/skills"); + fs::create_dir_all(&skills_dir).unwrap(); + + create_skill_dir(&skills_dir, "test-skill", "A test skill"); + create_skill_dir(&skills_dir, "another-skill", "Another skill"); + + let skills = discover_skills(Some(workspace), &[]); + + assert_eq!(skills.len(), 2); + assert_eq!(skills[0].name, "another-skill"); // Sorted alphabetically + assert_eq!(skills[1].name, "test-skill"); + } + + #[test] + fn test_discover_from_extra_paths() { + let temp = TempDir::new().unwrap(); + let extra_dir = temp.path().join("extra-skills"); + fs::create_dir_all(&extra_dir).unwrap(); + + create_skill_dir(&extra_dir, "extra-skill", "An extra skill"); + + let skills = discover_skills(None, &[extra_dir]); + + assert_eq!(skills.len(), 1); + assert_eq!(skills[0].name, "extra-skill"); + } + + #[test] + fn test_workspace_overrides_extra() { + let temp = TempDir::new().unwrap(); + let workspace = temp.path(); + + // Create extra skills directory + let extra_dir = temp.path().join("extra"); + fs::create_dir_all(&extra_dir).unwrap(); + create_skill_dir(&extra_dir, "shared-skill", "Extra version"); + + // Create workspace skills directory with same skill name + let workspace_skills = workspace.join(".g3/skills"); + fs::create_dir_all(&workspace_skills).unwrap(); + create_skill_dir(&workspace_skills, "shared-skill", "Workspace version"); + + let skills = discover_skills(Some(workspace), &[extra_dir]); + + assert_eq!(skills.len(), 1); + assert_eq!(skills[0].name, "shared-skill"); + assert_eq!(skills[0].description, "Workspace version"); + } + + #[test] + fn test_nonexistent_directory() { + let skills = discover_skills(Some(Path::new("/nonexistent/path")), &[]); + assert!(skills.is_empty()); + } + + #[test] + fn test_empty_directory() { + let temp = TempDir::new().unwrap(); + let skills_dir = temp.path().join(".g3/skills"); + fs::create_dir_all(&skills_dir).unwrap(); + + let skills = discover_skills(Some(temp.path()), &[]); + assert!(skills.is_empty()); + } + + #[test] + fn test_invalid_skill_skipped() { + let temp = TempDir::new().unwrap(); + let skills_dir = temp.path().join(".g3/skills"); + fs::create_dir_all(&skills_dir).unwrap(); + + // Create valid skill + create_skill_dir(&skills_dir, "valid-skill", "Valid"); + + // Create invalid skill (missing description) + let invalid_dir = skills_dir.join("invalid-skill"); + fs::create_dir_all(&invalid_dir).unwrap(); + fs::write( + invalid_dir.join("SKILL.md"), + "---\nname: invalid-skill\n---\n\nNo description." + ).unwrap(); + + let skills = discover_skills(Some(temp.path()), &[]); + + // Only valid skill should be loaded + assert_eq!(skills.len(), 1); + assert_eq!(skills[0].name, "valid-skill"); + } + + #[test] + fn test_lowercase_skill_md() { + let temp = TempDir::new().unwrap(); + let skills_dir = temp.path().join(".g3/skills"); + let skill_dir = skills_dir.join("lowercase-skill"); + fs::create_dir_all(&skill_dir).unwrap(); + + // Use lowercase skill.md + fs::write( + skill_dir.join("skill.md"), + "---\nname: lowercase-skill\ndescription: Uses lowercase filename\n---\n\nBody." + ).unwrap(); + + let skills = discover_skills(Some(temp.path()), &[]); + + assert_eq!(skills.len(), 1); + assert_eq!(skills[0].name, "lowercase-skill"); + } + + #[test] + fn test_expand_tilde() { + let expanded = expand_tilde("~/test/path"); + assert!(!expanded.to_string_lossy().starts_with('~')); + + let no_tilde = expand_tilde("/absolute/path"); + assert_eq!(no_tilde, PathBuf::from("/absolute/path")); + } +} diff --git a/crates/g3-core/src/skills/mod.rs b/crates/g3-core/src/skills/mod.rs new file mode 100644 index 0000000..0938992 --- /dev/null +++ b/crates/g3-core/src/skills/mod.rs @@ -0,0 +1,42 @@ +//! Agent Skills support for G3. +//! +//! Implements the Agent Skills specification (https://agentskills.io) +//! for discovering and using portable skill packages. +//! +//! # Overview +//! +//! Skills are packages of instructions that give the agent new capabilities. +//! Each skill is a directory containing a `SKILL.md` file with: +//! - YAML frontmatter (name, description, metadata) +//! - Markdown body with detailed instructions +//! +//! # Directory Structure +//! +//! ```text +//! skill-name/ +//! ├── SKILL.md # Required: instructions + metadata +//! ├── scripts/ # Optional: executable code +//! ├── references/ # Optional: additional documentation +//! └── assets/ # Optional: templates, data files +//! ``` +//! +//! # Discovery +//! +//! Skills are discovered from: +//! 1. Global: `~/.g3/skills/` (lowest priority) +//! 2. Extra paths from config (medium priority) +//! 3. Workspace: `.g3/skills/` (highest priority, overrides others) +//! +//! # Usage +//! +//! At startup, g3 scans skill directories and injects a summary into the +//! system prompt. When the agent needs a skill, it reads the full SKILL.md +//! using the `read_file` tool. + +mod parser; +mod discovery; +mod prompt; + +pub use parser::Skill; +pub use discovery::discover_skills; +pub use prompt::generate_skills_prompt; diff --git a/crates/g3-core/src/skills/parser.rs b/crates/g3-core/src/skills/parser.rs new file mode 100644 index 0000000..88cc5a1 --- /dev/null +++ b/crates/g3-core/src/skills/parser.rs @@ -0,0 +1,363 @@ +//! SKILL.md parser for Agent Skills specification. +//! +//! Parses YAML frontmatter and markdown body from SKILL.md files. +//! See: https://agentskills.io/specification + +use anyhow::{anyhow, Result}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::path::Path; + +/// A parsed Agent Skill from a SKILL.md file. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Skill { + /// Skill name (1-64 chars, lowercase alphanumeric + hyphens) + pub name: String, + /// Description of what the skill does and when to use it (1-1024 chars) + pub description: String, + /// Optional license + pub license: Option, + /// Optional compatibility requirements (max 500 chars) + pub compatibility: Option, + /// Optional arbitrary metadata + pub metadata: Option>, + /// Optional allowed tools (experimental) + pub allowed_tools: Option, + /// The full markdown body (after frontmatter) + pub body: String, + /// Absolute path to the SKILL.md file + pub path: String, +} + +/// Raw frontmatter structure for deserialization +#[derive(Debug, Deserialize)] +struct SkillFrontmatter { + name: Option, + description: Option, + license: Option, + compatibility: Option, + metadata: Option>, + #[serde(rename = "allowed-tools")] + allowed_tools: Option, +} + +impl Skill { + /// Parse a SKILL.md file from its content and path. + /// + /// The file must have YAML frontmatter delimited by `---` lines, + /// with at least `name` and `description` fields. + pub fn parse(content: &str, path: &Path) -> Result { + let (frontmatter, body) = split_frontmatter(content)?; + + let fm: SkillFrontmatter = serde_yaml::from_str(&frontmatter) + .map_err(|e| anyhow!("Invalid YAML frontmatter in {}: {}", path.display(), e))?; + + // Validate required fields + let name = fm.name.ok_or_else(|| { + anyhow!("Missing required 'name' field in {}", path.display()) + })?; + + let description = fm.description.ok_or_else(|| { + anyhow!("Missing required 'description' field in {}", path.display()) + })?; + + // Validate name format: 1-64 chars, lowercase alphanumeric + hyphens + validate_name(&name, path)?; + + // Validate description length: 1-1024 chars + if description.is_empty() { + return Err(anyhow!("Description cannot be empty in {}", path.display())); + } + if description.len() > 1024 { + return Err(anyhow!( + "Description exceeds 1024 characters ({} chars) in {}", + description.len(), + path.display() + )); + } + + // Validate compatibility length if present + if let Some(ref compat) = fm.compatibility { + if compat.len() > 500 { + return Err(anyhow!( + "Compatibility exceeds 500 characters ({} chars) in {}", + compat.len(), + path.display() + )); + } + } + + // Get absolute path + let abs_path = path.canonicalize() + .unwrap_or_else(|_| path.to_path_buf()) + .to_string_lossy() + .to_string(); + + Ok(Skill { + name, + description, + license: fm.license, + compatibility: fm.compatibility, + metadata: fm.metadata, + allowed_tools: fm.allowed_tools, + body: body.to_string(), + path: abs_path, + }) + } + + /// Parse a SKILL.md file from disk. + pub fn from_file(path: &Path) -> Result { + let content = std::fs::read_to_string(path) + .map_err(|e| anyhow!("Failed to read {}: {}", path.display(), e))?; + Self::parse(&content, path) + } +} + +/// Split content into frontmatter and body. +/// Frontmatter must be delimited by `---` lines at the start. +fn split_frontmatter(content: &str) -> Result<(String, String)> { + let content = content.trim_start(); + + // Must start with --- + if !content.starts_with("---") { + return Err(anyhow!("SKILL.md must start with YAML frontmatter (---)")); + } + + // Find the closing --- + let after_first = &content[3..]; + let closing_pos = after_first.find("\n---") + .ok_or_else(|| anyhow!("SKILL.md frontmatter not closed (missing ---)"))?; + + let frontmatter = after_first[..closing_pos].trim(); + let body = after_first[closing_pos + 4..].trim_start(); // Skip "\n---" + + if frontmatter.is_empty() { + return Err(anyhow!("SKILL.md frontmatter is empty")); + } + + Ok((frontmatter.to_string(), body.to_string())) +} + +/// Validate skill name format. +/// Must be 1-64 chars, lowercase alphanumeric + hyphens, +/// no leading/trailing/consecutive hyphens. +fn validate_name(name: &str, path: &Path) -> Result<()> { + if name.is_empty() { + return Err(anyhow!("Skill name cannot be empty in {}", path.display())); + } + + if name.len() > 64 { + return Err(anyhow!( + "Skill name exceeds 64 characters ({} chars) in {}", + name.len(), + path.display() + )); + } + + // Check for valid characters + for c in name.chars() { + if !c.is_ascii_lowercase() && !c.is_ascii_digit() && c != '-' { + return Err(anyhow!( + "Skill name contains invalid character '{}' in {} (must be lowercase alphanumeric or hyphen)", + c, + path.display() + )); + } + } + + // No leading/trailing hyphens + if name.starts_with('-') || name.ends_with('-') { + return Err(anyhow!( + "Skill name cannot start or end with hyphen in {}", + path.display() + )); + } + + // No consecutive hyphens + if name.contains("--") { + return Err(anyhow!( + "Skill name cannot contain consecutive hyphens in {}", + path.display() + )); + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + + fn test_path() -> PathBuf { + PathBuf::from("/test/skill/SKILL.md") + } + + #[test] + fn test_parse_valid_skill() { + let content = r#"--- +name: pdf-processing +description: Extract text and tables from PDF files using pdfplumber +license: Apache-2.0 +compatibility: Requires Python 3.8+ +metadata: + author: example-org + version: "1.0" +--- + +# PDF Processing + +Use this skill when working with PDF files. +"#; + + let skill = Skill::parse(content, &test_path()).unwrap(); + assert_eq!(skill.name, "pdf-processing"); + assert_eq!(skill.description, "Extract text and tables from PDF files using pdfplumber"); + assert_eq!(skill.license, Some("Apache-2.0".to_string())); + assert_eq!(skill.compatibility, Some("Requires Python 3.8+".to_string())); + assert!(skill.body.contains("# PDF Processing")); + + let metadata = skill.metadata.unwrap(); + assert_eq!(metadata.get("author"), Some(&"example-org".to_string())); + assert_eq!(metadata.get("version"), Some(&"1.0".to_string())); + } + + #[test] + fn test_parse_minimal_skill() { + let content = r#"--- +name: simple-skill +description: A simple skill +--- + +Body content here. +"#; + + let skill = Skill::parse(content, &test_path()).unwrap(); + assert_eq!(skill.name, "simple-skill"); + assert_eq!(skill.description, "A simple skill"); + assert!(skill.license.is_none()); + assert!(skill.compatibility.is_none()); + assert!(skill.metadata.is_none()); + } + + #[test] + fn test_missing_name() { + let content = r#"--- +description: A skill without a name +--- + +Body. +"#; + + let err = Skill::parse(content, &test_path()).unwrap_err(); + assert!(err.to_string().contains("Missing required 'name'")); + } + + #[test] + fn test_missing_description() { + let content = r#"--- +name: no-description +--- + +Body. +"#; + + let err = Skill::parse(content, &test_path()).unwrap_err(); + assert!(err.to_string().contains("Missing required 'description'")); + } + + #[test] + fn test_invalid_name_uppercase() { + let content = r#"--- +name: Invalid-Name +description: Has uppercase +--- + +Body. +"#; + + let err = Skill::parse(content, &test_path()).unwrap_err(); + assert!(err.to_string().contains("invalid character")); + } + + #[test] + fn test_invalid_name_leading_hyphen() { + let content = r#"--- +name: -leading-hyphen +description: Bad name +--- + +Body. +"#; + + let err = Skill::parse(content, &test_path()).unwrap_err(); + assert!(err.to_string().contains("cannot start or end with hyphen")); + } + + #[test] + fn test_invalid_name_consecutive_hyphens() { + let content = r#"--- +name: double--hyphen +description: Bad name +--- + +Body. +"#; + + let err = Skill::parse(content, &test_path()).unwrap_err(); + assert!(err.to_string().contains("consecutive hyphens")); + } + + #[test] + fn test_missing_frontmatter() { + let content = "# Just markdown\n\nNo frontmatter here."; + + let err = Skill::parse(content, &test_path()).unwrap_err(); + assert!(err.to_string().contains("must start with YAML frontmatter")); + } + + #[test] + fn test_unclosed_frontmatter() { + let content = r#"--- +name: unclosed +description: Missing closing delimiter + +Body without closing --- +"#; + + let err = Skill::parse(content, &test_path()).unwrap_err(); + assert!(err.to_string().contains("not closed")); + } + + #[test] + fn test_empty_frontmatter() { + let content = "---\n---\n\nBody."; + + let err = Skill::parse(content, &test_path()).unwrap_err(); + assert!(err.to_string().contains("frontmatter is empty")); + } + + #[test] + fn test_description_too_long() { + let long_desc = "x".repeat(1025); + let content = format!("---\nname: long-desc\ndescription: {}\n---\n\nBody.", long_desc); + + let err = Skill::parse(&content, &test_path()).unwrap_err(); + assert!(err.to_string().contains("exceeds 1024 characters")); + } + + #[test] + fn test_allowed_tools_field() { + let content = r#"--- +name: git-skill +description: Git operations +allowed-tools: Bash(git:*) Read +--- + +Body. +"#; + + let skill = Skill::parse(content, &test_path()).unwrap(); + assert_eq!(skill.allowed_tools, Some("Bash(git:*) Read".to_string())); + } +} diff --git a/crates/g3-core/src/skills/prompt.rs b/crates/g3-core/src/skills/prompt.rs new file mode 100644 index 0000000..2aa111a --- /dev/null +++ b/crates/g3-core/src/skills/prompt.rs @@ -0,0 +1,140 @@ +//! Generate XML prompt section for available skills. +//! +//! Creates the `` XML block that gets injected into +//! the system prompt to inform the agent about available skills. + +use super::parser::Skill; + +/// Generate the XML section for available skills. +/// +/// Returns an empty string if no skills are available. +/// The XML format follows the Agent Skills specification. +pub fn generate_skills_prompt(skills: &[Skill]) -> String { + if skills.is_empty() { + return String::new(); + } + + let mut xml = String::new(); + xml.push_str("# Available Skills\n\n"); + xml.push_str("You have access to the following skills. When a task matches a skill's description, \ + read the full skill file using `read_file` to get detailed instructions.\n\n"); + xml.push_str("\n"); + + for skill in skills { + xml.push_str(" \n"); + xml.push_str(&format!(" {}\n", escape_xml(&skill.name))); + xml.push_str(&format!(" {}\n", escape_xml(&skill.description))); + xml.push_str(&format!(" {}\n", escape_xml(&skill.path))); + + // Include compatibility info if present + if let Some(ref compat) = skill.compatibility { + xml.push_str(&format!(" {}\n", escape_xml(compat))); + } + + xml.push_str(" \n"); + } + + xml.push_str("\n"); + xml +} + +/// Escape special XML characters. +fn escape_xml(s: &str) -> String { + s.replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) + .replace('\'', "'") +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_skill(name: &str, description: &str, path: &str) -> Skill { + Skill { + name: name.to_string(), + description: description.to_string(), + license: None, + compatibility: None, + metadata: None, + allowed_tools: None, + body: String::new(), + path: path.to_string(), + } + } + + #[test] + fn test_empty_skills() { + let result = generate_skills_prompt(&[]); + assert!(result.is_empty()); + } + + #[test] + fn test_single_skill() { + let skills = vec![ + make_skill("pdf-processing", "Extract text from PDFs", "/home/user/.g3/skills/pdf-processing/SKILL.md"), + ]; + + let result = generate_skills_prompt(&skills); + + assert!(result.contains("")); + assert!(result.contains("")); + assert!(result.contains("pdf-processing")); + assert!(result.contains("Extract text from PDFs")); + assert!(result.contains("/home/user/.g3/skills/pdf-processing/SKILL.md")); + } + + #[test] + fn test_multiple_skills() { + let skills = vec![ + make_skill("skill-a", "First skill", "/path/a/SKILL.md"), + make_skill("skill-b", "Second skill", "/path/b/SKILL.md"), + ]; + + let result = generate_skills_prompt(&skills); + + assert!(result.contains("skill-a")); + assert!(result.contains("skill-b")); + // Should have exactly 2 skill blocks + assert_eq!(result.matches("").count(), 2); + assert_eq!(result.matches("").count(), 2); + } + + #[test] + fn test_xml_escaping() { + let skills = vec![ + make_skill("test-skill", "Handle & \"characters\"", "/path/SKILL.md"), + ]; + + let result = generate_skills_prompt(&skills); + + assert!(result.contains("<special>")); + assert!(result.contains("&")); + assert!(result.contains(""characters"")); + // Should not contain unescaped special chars in description + assert!(!result.contains("")); + } + + #[test] + fn test_with_compatibility() { + let mut skill = make_skill("docker-skill", "Docker operations", "/path/SKILL.md"); + skill.compatibility = Some("Requires Docker 20.0+".to_string()); + + let result = generate_skills_prompt(&[skill]); + + assert!(result.contains("Requires Docker 20.0+")); + } + + #[test] + fn test_header_text() { + let skills = vec![ + make_skill("test", "Test skill", "/path/SKILL.md"), + ]; + + let result = generate_skills_prompt(&skills); + + assert!(result.contains("# Available Skills")); + assert!(result.contains("read the full skill file using `read_file`")); + } +}