Merge feature/agent-skills-support: Agent Skills specification support

This commit is contained in:
Dhanji R. Prasanna
2026-02-05 12:46:53 +11:00
12 changed files with 1058 additions and 12 deletions

View File

@@ -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

View File

@@ -366,4 +366,49 @@ Orchestrates 7 g3 agents in sequence for codebase maintenance.
**Pipeline Order**: euler → breaker → hopper → fowler → carmack → lamport → huffman
**State Storage**: `analysis/sdlc/pipeline.json` (git-tracked)
**CLI**: `studio sdlc run [-c N]`, `studio sdlc status`, `studio sdlc reset`
**CLI**: `studio sdlc run [-c N]`, `studio sdlc status`, `studio sdlc reset`
### 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 `<available_skills>` 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...
```

View File

@@ -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;
@@ -173,12 +173,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,
);

View File

@@ -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
}

View File

@@ -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<String>,
language_content: Option<String>,
include_prompt: Option<String>,
skills_content: Option<String>,
workspace_dir: &Path,
) -> Option<String> {
// 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<String> = [
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<Skill>, Option<String>) {
if !skills_config.enabled {
return (Vec::new(), None);
}
// Convert extra_paths from config to PathBuf
let extra_paths: Vec<PathBuf> = 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();

View File

@@ -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<String>,
}
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(),
}
}
}

View File

@@ -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;

View File

@@ -118,17 +118,34 @@ write_file(\"helper.rs\", \"...\")
// COMPOSED PROMPTS
// ============================================================================
use crate::skills::{Skill, generate_skills_prompt};
/// System prompt for providers with native tool calling (Anthropic, OpenAI, etc.)
/// Uses include_str! to embed the prompt at compile time.
pub fn get_system_prompt_for_native() -> String {
EMBEDDED_NATIVE_PROMPT.to_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);
if skills_section.is_empty() {
EMBEDDED_NATIVE_PROMPT.to_string()
} else {
format!("{}\n\n{}", EMBEDDED_NATIVE_PROMPT, skills_section)
}
}
/// 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 {
// For non-native, we still need to inject the tool format instructions
// We take the native prompt and insert the non-native sections after the intro
let native = EMBEDDED_NATIVE_PROMPT;
let native = get_system_prompt_for_native_with_skills(skills);
// Find the end of the intro section (after the first major heading)
// The intro ends before "# Task Management with Plan Mode"
@@ -163,9 +180,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())
@@ -254,4 +279,50 @@ mod tests {
assert!(!prompt.is_empty(), "Embedded prompt should not be empty");
assert!(prompt.starts_with("# G3 System Prompt"), "Prompt should start with header");
}
#[test]
fn test_native_prompt_without_skills() {
let prompt = get_system_prompt_for_native_with_skills(&[]);
assert!(!prompt.contains("<available_skills>"));
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("<available_skills>"));
assert!(prompt.contains("<name>test-skill</name>"));
assert!(prompt.contains("<description>A test skill for unit testing</description>"));
assert!(prompt.contains("<location>/path/to/test-skill/SKILL.md</location>"));
}
#[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("<name>agent-skill</name>"));
}
}

View File

@@ -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<Skill> {
let mut skills_by_name: HashMap<String, Skill> = 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<Skill> = 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<String, Skill>) {
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<String, Skill>) {
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"));
}
}

View File

@@ -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;

View File

@@ -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<String>,
/// Optional compatibility requirements (max 500 chars)
pub compatibility: Option<String>,
/// Optional arbitrary metadata
pub metadata: Option<HashMap<String, String>>,
/// Optional allowed tools (experimental)
pub allowed_tools: Option<String>,
/// 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<String>,
description: Option<String>,
license: Option<String>,
compatibility: Option<String>,
metadata: Option<HashMap<String, String>>,
#[serde(rename = "allowed-tools")]
allowed_tools: Option<String>,
}
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<Self> {
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<Self> {
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()));
}
}

View File

@@ -0,0 +1,140 @@
//! Generate XML prompt section for available skills.
//!
//! Creates the `<available_skills>` 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("<available_skills>\n");
for skill in skills {
xml.push_str(" <skill>\n");
xml.push_str(&format!(" <name>{}</name>\n", escape_xml(&skill.name)));
xml.push_str(&format!(" <description>{}</description>\n", escape_xml(&skill.description)));
xml.push_str(&format!(" <location>{}</location>\n", escape_xml(&skill.path)));
// Include compatibility info if present
if let Some(ref compat) = skill.compatibility {
xml.push_str(&format!(" <compatibility>{}</compatibility>\n", escape_xml(compat)));
}
xml.push_str(" </skill>\n");
}
xml.push_str("</available_skills>\n");
xml
}
/// Escape special XML characters.
fn escape_xml(s: &str) -> String {
s.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
.replace('\'', "&apos;")
}
#[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("<available_skills>"));
assert!(result.contains("</available_skills>"));
assert!(result.contains("<name>pdf-processing</name>"));
assert!(result.contains("<description>Extract text from PDFs</description>"));
assert!(result.contains("<location>/home/user/.g3/skills/pdf-processing/SKILL.md</location>"));
}
#[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("<name>skill-a</name>"));
assert!(result.contains("<name>skill-b</name>"));
// Should have exactly 2 skill blocks
assert_eq!(result.matches("<skill>").count(), 2);
assert_eq!(result.matches("</skill>").count(), 2);
}
#[test]
fn test_xml_escaping() {
let skills = vec![
make_skill("test-skill", "Handle <special> & \"characters\"", "/path/SKILL.md"),
];
let result = generate_skills_prompt(&skills);
assert!(result.contains("&lt;special&gt;"));
assert!(result.contains("&amp;"));
assert!(result.contains("&quot;characters&quot;"));
// Should not contain unescaped special chars in description
assert!(!result.contains("<special>"));
}
#[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("<compatibility>Requires Docker 20.0+</compatibility>"));
}
#[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`"));
}
}