feat: implement Agent Skills specification support

Implements the Agent Skills specification (https://agentskills.io) for
portable skill packages that give the agent new capabilities.

Changes:
- Add skills module with SKILL.md parser (YAML frontmatter + markdown body)
- Implement skill discovery from ~/.g3/skills/, config extra_paths, and .g3/skills/
- Generate <available_skills> XML for system prompt injection
- Add SkillsConfig to g3-config with enabled flag and extra_paths
- Wire skills discovery into CLI startup
- Add 29 unit tests for parser, discovery, and prompt generation
- Update README with Agent Skills documentation

Skill locations (priority order):
1. ~/.g3/skills/ (global)
2. Config extra_paths
3. .g3/skills/ (workspace, highest priority)

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.
This commit is contained in:
Dhanji R. Prasanna
2026-02-04 12:58:57 +11:00
parent 95d9847354
commit a5f6475603
12 changed files with 1072 additions and 15 deletions

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`"));
}
}