use std::{
collections::{HashMap, HashSet, VecDeque},
env, fmt, fs, io,
path::{Path, PathBuf},
};
use serde::{Deserialize, Serialize};
use crate::{
daat_locus_paths::daat_locus_paths_sync,
persistence::{PersistenceFileMode, PersistenceStore, write_bytes_atomic_sync},
};
const SKILL_FILE_NAME: &str = "SKILL.md";
const OPENSKILLS_CONFIG_FILE_NAME: &str = "openskills.toml";
const AGENTS_DIR_NAME: &str = ".agents";
const SKILLS_DIR_NAME: &str = "skills";
const SKILL_METADATA_BUDGET_CHARS: usize = 8_000;
const MAX_SCAN_DEPTH: usize = 6;
const MAX_SKILL_DIRS_PER_ROOT: usize = 2_000;
const MAX_NAME_LEN: usize = 64;
const MAX_DESCRIPTION_LEN: usize = 1_024;
mod builtin_skill_bindings {
include!(concat!(env!("OUT_DIR"), "/builtin_skills.rs"));
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct OpenSkillsCatalog {
skills: Vec<OpenSkill>,
errors: Vec<OpenSkillError>,
roots: Vec<PathBuf>,
root_by_skill_path: HashMap<PathBuf, PathBuf>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OpenSkill {
pub name: String,
pub description: String,
pub path: PathBuf,
pub scope: OpenSkillScope,
pub allow_implicit_invocation: bool,
pub user_disabled: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum OpenSkillScope {
Project,
DaatLocusHome,
User,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OpenSkillError {
pub path: PathBuf,
pub message: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct OpenSkillDashboardSummary {
pub name: String,
pub description: String,
pub path: String,
pub scope: String,
pub allow_implicit_invocation: bool,
pub user_disabled: bool,
pub auto_use_enabled: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct OpenSkillDashboardError {
pub path: String,
pub message: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OpenSkillInjection {
pub name: String,
pub path: PathBuf,
pub contents: String,
}
#[derive(Debug, Clone)]
struct SkillRoot {
path: PathBuf,
scope: OpenSkillScope,
}
#[derive(Debug, Deserialize)]
struct SkillFrontmatter {
#[serde(default)]
name: Option<String>,
#[serde(default)]
description: Option<String>,
}
#[derive(Debug, Default, Deserialize)]
struct OpenAiSkillMetadata {
#[serde(default)]
policy: OpenAiSkillPolicy,
}
#[derive(Debug, Default, Deserialize)]
struct OpenAiSkillPolicy {
#[serde(default)]
allow_implicit_invocation: Option<bool>,
}
#[derive(Debug, Default, Serialize, Deserialize)]
struct OpenSkillsUserConfig {
#[serde(default)]
disabled_paths: Vec<PathBuf>,
}
#[derive(Debug)]
enum SkillParseError {
Read(io::Error),
MissingFrontmatter,
InvalidYaml(serde_yaml::Error),
MissingField(&'static str),
InvalidField { field: &'static str, reason: String },
}
impl fmt::Display for SkillParseError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Read(err) => write!(f, "failed to read file: {err}"),
Self::MissingFrontmatter => {
write!(f, "missing YAML frontmatter delimited by ---")
}
Self::InvalidYaml(err) => write!(f, "invalid YAML: {err}"),
Self::MissingField(field) => write!(f, "missing field `{field}`"),
Self::InvalidField { field, reason } => write!(f, "invalid {field}: {reason}"),
}
}
}
impl OpenSkillsCatalog {
#[cfg(test)]
fn skills(&self) -> &[OpenSkill] {
&self.skills
}
pub(crate) fn skill_for_path(&self, path: &std::path::Path) -> Option<&OpenSkill> {
self.skills.iter().find(|skill| skill.path == path)
}
#[cfg(test)]
fn errors(&self) -> &[OpenSkillError] {
&self.errors
}
pub fn render_prompt_block(&self) -> Option<String> {
self.render_prompt_block_with_budget(SKILL_METADATA_BUDGET_CHARS)
}
pub fn dashboard_summaries(&self) -> Vec<OpenSkillDashboardSummary> {
self.skills
.iter()
.map(|skill| OpenSkillDashboardSummary {
name: skill.name.clone(),
description: skill.description.clone(),
path: display_path(&skill.path),
scope: skill.scope.label().to_string(),
allow_implicit_invocation: skill.allow_implicit_invocation,
user_disabled: skill.user_disabled,
auto_use_enabled: skill.auto_use_enabled(),
})
.collect()
}
pub fn dashboard_errors(&self) -> Vec<OpenSkillDashboardError> {
self.errors
.iter()
.map(|error| OpenSkillDashboardError {
path: display_path(&error.path),
message: error.message.clone(),
})
.collect()
}
fn render_prompt_block_with_budget(&self, metadata_budget_chars: usize) -> Option<String> {
let skills = self
.skills
.iter()
.filter(|skill| skill.auto_use_enabled())
.collect::<Vec<_>>();
if skills.is_empty() {
return None;
}
let alias_plan = SkillPathAliasPlan::new(self, &skills);
let skill_lines = render_skill_lines(&skills, &alias_plan, metadata_budget_chars.max(1));
if skill_lines.lines.is_empty() {
return None;
}
let mut lines = vec![
"## Skills".to_string(),
"A skill is a set of local instructions stored in a `SKILL.md` file. Below is the list of OpenSkills available in this session. Each entry includes a name, description, and path so the model can read the source instructions when a skill applies.".to_string(),
];
if !alias_plan.root_lines.is_empty() {
lines.push("### Skill roots".to_string());
lines.extend(alias_plan.root_lines.clone());
}
lines.push("### Available skills".to_string());
lines.extend(skill_lines.lines);
if let Some(warning) = skill_lines.warning {
lines.push(warning);
}
lines.push("### How to use skills".to_string());
if alias_plan.root_lines.is_empty() {
lines.push(SKILLS_HOW_TO_USE_WITH_ABSOLUTE_PATHS.to_string());
} else {
lines.push(SKILLS_HOW_TO_USE_WITH_ALIASES.to_string());
}
Some(lines.join("\n"))
}
pub fn explicit_skill_injections_for_text(&self, text: &str) -> Vec<OpenSkillInjection> {
let mentions = extract_dollar_mentions(text);
if mentions.is_empty() {
return Vec::new();
}
let name_counts = skill_name_counts(&self.skills);
let mut seen_paths = HashSet::new();
let mut injections = Vec::new();
for skill in &self.skills {
if !mentions.contains(skill.name.as_str()) {
continue;
}
if name_counts.get(skill.name.as_str()).copied().unwrap_or(0) != 1 {
continue;
}
if !seen_paths.insert(skill.path.clone()) {
continue;
}
match fs::read_to_string(&skill.path) {
Ok(contents) => injections.push(OpenSkillInjection {
name: skill.name.clone(),
path: skill.path.clone(),
contents,
}),
Err(err) => tracing::warn!(
"failed to read explicitly mentioned skill {} at {}: {err}",
skill.name,
skill.path.display()
),
}
}
injections
}
}
impl OpenSkillScope {
const fn rank(self) -> u8 {
match self {
Self::Project => 0,
Self::DaatLocusHome => 1,
Self::User => 2,
}
}
const fn label(self) -> &'static str {
match self {
Self::Project => "project",
Self::DaatLocusHome => "daat-locus",
Self::User => "user",
}
}
}
impl OpenSkill {
pub const fn auto_use_enabled(&self) -> bool {
self.allow_implicit_invocation && !self.user_disabled
}
}
pub fn load_openskills_for_runtime(execution_cwd: &Path) -> OpenSkillsCatalog {
ensure_builtin_skills_on_disk();
let disabled_paths = load_openskills_user_config().disabled_paths;
let catalog =
load_openskills_from_roots_with_disabled_paths(skill_roots(execution_cwd), disabled_paths);
for error in &catalog.errors {
tracing::warn!(
"failed to load OpenSkill at {}: {}",
error.path.display(),
error.message
);
}
catalog
}
fn ensure_builtin_skills_on_disk() {
let target_dir = daat_locus_paths_sync().root().join(SKILLS_DIR_NAME);
if let Err(err) = fs::create_dir_all(&target_dir) {
tracing::warn!(
"failed to create builtin skills dir {}: {err}",
target_dir.display()
);
return;
}
for (name, content) in builtin_skill_bindings::BUILTIN_SKILL_SOURCES {
let skill_dir = target_dir.join(name);
if let Err(err) = fs::create_dir_all(&skill_dir) {
tracing::warn!(
"failed to create builtin skill dir {}: {err}",
skill_dir.display()
);
continue;
}
let skill_file = skill_dir.join(SKILL_FILE_NAME);
let need_write =
fs::read_to_string(&skill_file).map_or(true, |existing| existing != *content);
if need_write && let Err(err) = fs::write(&skill_file, content) {
tracing::warn!(
"failed to write builtin skill {}: {err}",
skill_file.display()
);
}
}
}
pub fn reload_openskills_for_runtime(execution_cwd: &Path) -> OpenSkillsCatalog {
load_openskills_for_runtime(execution_cwd)
}
pub fn set_openskill_auto_use(path: &Path, enabled: bool) -> Result<(), String> {
let target = canonicalize_lossy(path);
let mut config = load_openskills_user_config();
config.disabled_paths = config
.disabled_paths
.into_iter()
.map(|path| canonicalize_lossy(&path))
.filter(|path| path != &target)
.collect();
if !enabled {
config.disabled_paths.push(target);
}
save_openskills_user_config(&mut config)
}
fn skill_roots(execution_cwd: &Path) -> Vec<SkillRoot> {
let mut roots = Vec::new();
roots.extend(project_skill_roots(execution_cwd));
roots.push(SkillRoot {
path: daat_locus_paths_sync().root().join(SKILLS_DIR_NAME),
scope: OpenSkillScope::DaatLocusHome,
});
if let Some(home) = env::home_dir() {
roots.push(SkillRoot {
path: home.join(AGENTS_DIR_NAME).join(SKILLS_DIR_NAME),
scope: OpenSkillScope::User,
});
}
dedupe_roots(roots)
}
fn project_skill_roots(execution_cwd: &Path) -> Vec<SkillRoot> {
let cwd = canonicalize_lossy(execution_cwd);
let project_root = find_project_root(&cwd);
let mut dirs = cwd
.ancestors()
.scan(false, |done, dir| {
if *done {
None
} else {
if dir == project_root {
*done = true;
}
Some(dir.to_path_buf())
}
})
.collect::<Vec<_>>();
dirs.reverse();
dirs.into_iter()
.map(|dir| SkillRoot {
path: dir.join(AGENTS_DIR_NAME).join(SKILLS_DIR_NAME),
scope: OpenSkillScope::Project,
})
.collect()
}
fn find_project_root(cwd: &Path) -> &Path {
const MARKERS: &[&str] = &[
".git",
"AGENTS.md",
"Cargo.toml",
"package.json",
"pyproject.toml",
"go.mod",
];
cwd.ancestors()
.find(|dir| MARKERS.iter().any(|marker| dir.join(marker).exists()))
.unwrap_or(cwd)
}
fn dedupe_roots(roots: Vec<SkillRoot>) -> Vec<SkillRoot> {
let mut seen = HashSet::new();
roots
.into_iter()
.filter(|root| seen.insert(canonicalize_lossy(&root.path)))
.collect()
}
#[cfg(test)]
fn load_openskills_from_roots(roots: Vec<SkillRoot>) -> OpenSkillsCatalog {
load_openskills_from_roots_with_disabled_paths(roots, Vec::new())
}
fn load_openskills_from_roots_with_disabled_paths(
roots: Vec<SkillRoot>,
disabled_paths: Vec<PathBuf>,
) -> OpenSkillsCatalog {
let mut catalog = OpenSkillsCatalog::default();
let mut root_by_skill_path = HashMap::new();
let mut used_roots = Vec::new();
let disabled_paths = disabled_paths
.into_iter()
.map(|path| canonicalize_lossy(&path))
.collect::<HashSet<_>>();
for root in roots {
let root_path = canonicalize_lossy(&root.path);
let before = catalog.skills.len();
discover_skills_under_root(&root_path, root.scope, &mut catalog);
if catalog.skills.len() > before {
used_roots.push(root_path.clone());
for skill in &catalog.skills[before..] {
root_by_skill_path
.entry(skill.path.clone())
.or_insert_with(|| root_path.clone());
}
}
}
let mut seen_paths = HashSet::new();
catalog
.skills
.retain(|skill| seen_paths.insert(skill.path.clone()));
let retained_paths = catalog
.skills
.iter()
.map(|skill| skill.path.clone())
.collect::<HashSet<_>>();
root_by_skill_path.retain(|path, _| retained_paths.contains(path));
let roots_with_skills = root_by_skill_path
.values()
.cloned()
.collect::<HashSet<PathBuf>>();
used_roots.retain(|root| roots_with_skills.contains(root));
used_roots.sort();
used_roots.dedup();
catalog.roots = used_roots;
catalog.root_by_skill_path = root_by_skill_path;
for skill in &mut catalog.skills {
skill.user_disabled = disabled_paths.contains(&skill.path);
}
catalog.skills.sort_by(|a, b| {
a.scope
.rank()
.cmp(&b.scope.rank())
.then_with(|| a.name.cmp(&b.name))
.then_with(|| a.path.cmp(&b.path))
});
catalog
}
fn openskills_user_config_path() -> PathBuf {
PersistenceStore::runtime_sync().config_file(OPENSKILLS_CONFIG_FILE_NAME)
}
fn load_openskills_user_config() -> OpenSkillsUserConfig {
let path = openskills_user_config_path();
let content = match fs::read_to_string(&path) {
Ok(content) => content,
Err(err) if err.kind() == io::ErrorKind::NotFound => {
return OpenSkillsUserConfig::default();
}
Err(err) => {
tracing::warn!("failed to read OpenSkills config {}: {err}", path.display());
return OpenSkillsUserConfig::default();
}
};
match toml::from_str::<OpenSkillsUserConfig>(&content) {
Ok(mut config) => {
normalize_openskills_user_config(&mut config);
config
}
Err(err) => {
tracing::warn!(
"failed to parse OpenSkills config {}: {err}",
path.display()
);
OpenSkillsUserConfig::default()
}
}
}
fn save_openskills_user_config(config: &mut OpenSkillsUserConfig) -> Result<(), String> {
normalize_openskills_user_config(config);
let path = openskills_user_config_path();
if config.disabled_paths.is_empty() {
match fs::remove_file(&path) {
Ok(()) => return Ok(()),
Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(()),
Err(err) => {
return Err(format!(
"failed to remove OpenSkills config {}: {err}",
path.display()
));
}
}
}
let content = toml::to_string_pretty(config)
.map_err(|err| format!("failed to encode OpenSkills config: {err}"))?;
write_bytes_atomic_sync(&path, content.as_bytes(), PersistenceFileMode::Private).map_err(
|err| {
format!(
"failed to write OpenSkills config {}: {err}",
path.display()
)
},
)
}
fn normalize_openskills_user_config(config: &mut OpenSkillsUserConfig) {
config.disabled_paths = config
.disabled_paths
.drain(..)
.map(|path| canonicalize_lossy(&path))
.collect();
config.disabled_paths.sort();
config.disabled_paths.dedup();
}
fn discover_skills_under_root(root: &Path, scope: OpenSkillScope, catalog: &mut OpenSkillsCatalog) {
if !root.is_dir() {
return;
}
let mut queue = VecDeque::from([(root.to_path_buf(), 0usize)]);
let mut visited = HashSet::from([canonicalize_lossy(root)]);
let mut truncated = false;
while let Some((dir, depth)) = queue.pop_front() {
let entries = match fs::read_dir(&dir) {
Ok(entries) => entries,
Err(err) => {
catalog.errors.push(OpenSkillError {
path: dir,
message: format!("failed to read skills directory: {err}"),
});
continue;
}
};
for entry in entries.flatten() {
let path = entry.path();
let file_name = entry.file_name();
let file_name = file_name.to_string_lossy();
if file_name.starts_with('.') {
continue;
}
let metadata = match fs::metadata(&path) {
Ok(metadata) => metadata,
Err(err) => {
catalog.errors.push(OpenSkillError {
path,
message: format!("failed to stat skills path: {err}"),
});
continue;
}
};
if metadata.is_dir() {
if depth >= MAX_SCAN_DEPTH {
continue;
}
if visited.len() >= MAX_SKILL_DIRS_PER_ROOT {
truncated = true;
continue;
}
let canonical = canonicalize_lossy(&path);
if visited.insert(canonical.clone()) {
queue.push_back((canonical, depth + 1));
}
continue;
}
if metadata.is_file() && file_name == SKILL_FILE_NAME {
match parse_skill_file(&path, scope) {
Ok(skill) => catalog.skills.push(skill),
Err(err) => catalog.errors.push(OpenSkillError {
path: path.clone(),
message: err.to_string(),
}),
}
}
}
}
if truncated {
catalog.errors.push(OpenSkillError {
path: root.to_path_buf(),
message: format!("skills scan truncated after {MAX_SKILL_DIRS_PER_ROOT} directories"),
});
}
}
fn parse_skill_file(path: &Path, scope: OpenSkillScope) -> Result<OpenSkill, SkillParseError> {
let contents = fs::read_to_string(path).map_err(SkillParseError::Read)?;
let frontmatter = extract_frontmatter(&contents).ok_or(SkillParseError::MissingFrontmatter)?;
let parsed: SkillFrontmatter =
serde_yaml::from_str(&frontmatter).map_err(SkillParseError::InvalidYaml)?;
let name = parsed
.name
.as_deref()
.map(sanitize_single_line)
.filter(|value| !value.is_empty())
.unwrap_or_else(|| default_skill_name(path));
validate_required_len(&name, MAX_NAME_LEN, "name")?;
let description = parsed
.description
.as_deref()
.map(sanitize_single_line)
.unwrap_or_default();
validate_required_len(&description, MAX_DESCRIPTION_LEN, "description")?;
Ok(OpenSkill {
name,
description,
path: canonicalize_lossy(path),
scope,
allow_implicit_invocation: load_allow_implicit_invocation(path),
user_disabled: false,
})
}
fn load_allow_implicit_invocation(skill_path: &Path) -> bool {
let Some(skill_dir) = skill_path.parent() else {
return true;
};
let metadata_path = skill_dir.join("agents").join("openai.yaml");
let Ok(contents) = fs::read_to_string(metadata_path) else {
return true;
};
serde_yaml::from_str::<OpenAiSkillMetadata>(&contents)
.ok()
.and_then(|metadata| metadata.policy.allow_implicit_invocation)
.unwrap_or(true)
}
fn default_skill_name(path: &Path) -> String {
path.parent()
.and_then(|parent| parent.file_name())
.map(|name| sanitize_single_line(&name.to_string_lossy()))
.filter(|name| !name.is_empty())
.unwrap_or_else(|| "skill".to_string())
}
fn extract_frontmatter(contents: &str) -> Option<String> {
let mut lines = contents.lines();
if !matches!(lines.next(), Some(line) if line.trim() == "---") {
return None;
}
let mut frontmatter_lines = Vec::new();
let mut found_closing = false;
for line in lines.by_ref() {
if line.trim() == "---" {
found_closing = true;
break;
}
frontmatter_lines.push(line);
}
(!frontmatter_lines.is_empty() && found_closing).then(|| frontmatter_lines.join("\n"))
}
fn sanitize_single_line(raw: &str) -> String {
raw.split_whitespace().collect::<Vec<_>>().join(" ")
}
fn validate_required_len(
value: &str,
max_len: usize,
field: &'static str,
) -> Result<(), SkillParseError> {
if value.is_empty() {
return Err(SkillParseError::MissingField(field));
}
if value.chars().count() > max_len {
return Err(SkillParseError::InvalidField {
field,
reason: format!("exceeds maximum length of {max_len} characters"),
});
}
Ok(())
}
fn canonicalize_lossy(path: &Path) -> PathBuf {
path.canonicalize().unwrap_or_else(|_| path.to_path_buf())
}
struct SkillPathAliasPlan {
root_lines: Vec<String>,
root_aliases: HashMap<PathBuf, String>,
root_by_skill_path: HashMap<PathBuf, PathBuf>,
}
impl SkillPathAliasPlan {
fn new(catalog: &OpenSkillsCatalog, skills: &[&OpenSkill]) -> Self {
let skill_paths = skills
.iter()
.map(|skill| skill.path.clone())
.collect::<HashSet<_>>();
let mut roots = catalog
.roots
.iter()
.filter(|root| {
catalog
.root_by_skill_path
.iter()
.any(|(path, skill_root)| skill_paths.contains(path) && skill_root == *root)
})
.cloned()
.collect::<Vec<_>>();
roots.sort();
roots.dedup();
let root_aliases = roots
.iter()
.enumerate()
.map(|(index, root)| (root.clone(), format!("r{index}")))
.collect::<HashMap<_, _>>();
let root_lines = roots
.iter()
.enumerate()
.map(|(index, root)| format!("- `r{index}` = `{}`", display_path(root)))
.collect();
let root_by_skill_path = catalog
.root_by_skill_path
.iter()
.filter(|(path, _)| skill_paths.contains(*path))
.map(|(path, root)| (path.clone(), root.clone()))
.collect();
Self {
root_lines,
root_aliases,
root_by_skill_path,
}
}
fn render_path(&self, skill: &OpenSkill) -> String {
let Some(root) = self.root_by_skill_path.get(&skill.path) else {
return display_path(&skill.path);
};
let Some(alias) = self.root_aliases.get(root) else {
return display_path(&skill.path);
};
let Ok(relative) = skill.path.strip_prefix(root) else {
return display_path(&skill.path);
};
let relative = display_path(relative);
if relative.is_empty() {
alias.clone()
} else {
format!("{alias}/{relative}")
}
}
}
struct RenderedSkillLines {
lines: Vec<String>,
warning: Option<String>,
}
fn render_skill_lines(
skills: &[&OpenSkill],
alias_plan: &SkillPathAliasPlan,
budget_chars: usize,
) -> RenderedSkillLines {
let full_lines = skills
.iter()
.map(|skill| render_skill_line(skill, alias_plan, true))
.collect::<Vec<_>>();
if lines_char_cost(&full_lines) <= budget_chars {
return RenderedSkillLines {
lines: full_lines,
warning: None,
};
}
let minimum_lines = skills
.iter()
.map(|skill| render_skill_line(skill, alias_plan, false))
.collect::<Vec<_>>();
if lines_char_cost(&minimum_lines) <= budget_chars {
return RenderedSkillLines {
lines: minimum_lines,
warning: Some(
"warning: Skill descriptions were omitted to fit the skills metadata budget."
.to_string(),
),
};
}
let mut lines = Vec::new();
let mut used = 0usize;
let mut omitted = 0usize;
for line in minimum_lines {
let cost = line.chars().count().saturating_add(1);
if used.saturating_add(cost) <= budget_chars {
used = used.saturating_add(cost);
lines.push(line);
} else {
omitted = omitted.saturating_add(1);
}
}
RenderedSkillLines {
lines,
warning: (omitted > 0).then(|| {
format!(
"warning: Exceeded skills metadata budget; {omitted} additional skill(s) were not included."
)
}),
}
}
fn render_skill_line(
skill: &OpenSkill,
alias_plan: &SkillPathAliasPlan,
include_description: bool,
) -> String {
let path = alias_plan.render_path(skill);
if include_description {
format!("- {}: {} (file: {})", skill.name, skill.description, path)
} else {
format!("- {}: (file: {})", skill.name, path)
}
}
fn lines_char_cost(lines: &[String]) -> usize {
lines
.iter()
.map(|line| line.chars().count().saturating_add(1))
.sum()
}
fn display_path(path: &Path) -> String {
path.to_string_lossy().replace('\\', "/")
}
fn skill_name_counts(skills: &[OpenSkill]) -> HashMap<&str, usize> {
let mut counts = HashMap::new();
for skill in skills {
*counts.entry(skill.name.as_str()).or_insert(0) += 1;
}
counts
}
fn extract_dollar_mentions(text: &str) -> HashSet<&str> {
let bytes = text.as_bytes();
let mut mentions = HashSet::new();
let mut index = 0usize;
while index < bytes.len() {
if bytes[index] != b'$' {
index += 1;
continue;
}
let name_start = index + 1;
let Some(first) = bytes.get(name_start) else {
index += 1;
continue;
};
if !is_mention_name_char(*first) {
index += 1;
continue;
}
let mut name_end = name_start + 1;
while let Some(next) = bytes.get(name_end)
&& is_mention_name_char(*next)
{
name_end += 1;
}
let name = &text[name_start..name_end];
if !is_common_env_var(name) {
mentions.insert(name);
}
index = name_end;
}
mentions
}
const fn is_mention_name_char(byte: u8) -> bool {
matches!(byte, b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9' | b'_' | b'-' | b':')
}
fn is_common_env_var(name: &str) -> bool {
matches!(
name.to_ascii_uppercase().as_str(),
"PATH" | "HOME" | "USER" | "SHELL" | "PWD" | "TMPDIR" | "TEMP" | "TMP" | "LANG" | "TERM"
)
}
const SKILLS_HOW_TO_USE_WITH_ABSOLUTE_PATHS: &str = r"- Discovery: The list above is the OpenSkills available in this session (name + description + file path). Skill bodies live on disk at the listed paths.
- Trigger rules: If the user names a skill with `$SkillName` or plain text, or the task clearly matches a skill description above, use that skill for the current turn. Multiple mentions mean use them all. Do not carry skills across turns unless re-mentioned.
- How to use a skill:
1. Before task actions that rely on a skill, use Terminal or Coding tools as needed and read that skill's `SKILL.md` completely.
2. Resolve relative paths mentioned by `SKILL.md` relative to the directory containing that `SKILL.md`.
3. Read only the referenced files needed for the task; avoid loading unrelated `references/`, `scripts/`, or `assets/`.
4. Prefer running or adapting provided `scripts/` and reusing provided `assets/` or templates when applicable.
- If a named skill is missing, unreadable, or invalid, say so briefly and continue with the best fallback.";
const SKILLS_HOW_TO_USE_WITH_ALIASES: &str = r"- Discovery: The list above is the OpenSkills available in this session (name + description + short path). Expand short paths with the matching alias from `### Skill roots`.
- Trigger rules: If the user names a skill with `$SkillName` or plain text, or the task clearly matches a skill description above, use that skill for the current turn. Multiple mentions mean use them all. Do not carry skills across turns unless re-mentioned.
- How to use a skill:
1. Expand the listed short `file` path with `### Skill roots`, then use Terminal or Coding tools as needed and read that skill's `SKILL.md` completely before task actions that rely on it.
2. Resolve relative paths mentioned by `SKILL.md` relative to the directory containing that expanded `SKILL.md`.
3. Read only the referenced files needed for the task; avoid loading unrelated `references/`, `scripts/`, or `assets/`.
4. Prefer running or adapting provided `scripts/` and reusing provided `assets/` or templates when applicable.
- If a named skill is missing, unreadable, or invalid, say so briefly and continue with the best fallback.";
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn loads_skill_from_project_agents_skills() {
let temp = tempfile::tempdir().expect("tempdir");
let skill_dir = temp.path().join(".agents").join("skills").join("charts");
fs::create_dir_all(&skill_dir).expect("create skill dir");
fs::write(
skill_dir.join(SKILL_FILE_NAME),
"---\nname: charts\ndescription: Build charts from metrics\n---\n\n# Charts\n",
)
.expect("write skill");
let catalog = load_openskills_from_roots(vec![SkillRoot {
path: temp.path().join(".agents").join("skills"),
scope: OpenSkillScope::Project,
}]);
assert_eq!(catalog.skills().len(), 1);
assert_eq!(catalog.skills()[0].name, "charts");
let prompt = catalog
.render_prompt_block_with_budget(8_000)
.expect("prompt");
assert!(prompt.contains("charts: Build charts from metrics"));
assert!(prompt.contains("### Skill roots"));
assert!(prompt.contains("r0/charts/SKILL.md"));
}
#[test]
fn reports_invalid_skill_frontmatter() {
let temp = tempfile::tempdir().expect("tempdir");
let skill_dir = temp.path().join(".agents").join("skills").join("broken");
fs::create_dir_all(&skill_dir).expect("create skill dir");
fs::write(skill_dir.join(SKILL_FILE_NAME), "# Missing frontmatter\n").expect("write skill");
let catalog = load_openskills_from_roots(vec![SkillRoot {
path: temp.path().join(".agents").join("skills"),
scope: OpenSkillScope::Project,
}]);
assert!(catalog.skills().is_empty());
assert_eq!(catalog.errors().len(), 1);
assert!(
catalog.errors()[0]
.message
.contains("missing YAML frontmatter")
);
}
#[test]
fn respects_allow_implicit_invocation_policy() {
let temp = tempfile::tempdir().expect("tempdir");
let skill_dir = temp.path().join(".agents").join("skills").join("private");
fs::create_dir_all(skill_dir.join("agents")).expect("create metadata dir");
fs::write(
skill_dir.join(SKILL_FILE_NAME),
"---\nname: private\ndescription: Hidden unless explicitly invoked\n---\n\n# Private\n",
)
.expect("write skill");
fs::write(
skill_dir.join("agents").join("openai.yaml"),
"policy:\n allow_implicit_invocation: false\n",
)
.expect("write metadata");
let catalog = load_openskills_from_roots(vec![SkillRoot {
path: temp.path().join(".agents").join("skills"),
scope: OpenSkillScope::Project,
}]);
assert_eq!(catalog.skills().len(), 1);
assert!(catalog.render_prompt_block_with_budget(8_000).is_none());
}
#[test]
fn renders_minimum_lines_when_descriptions_exceed_budget() {
let temp = tempfile::tempdir().expect("tempdir");
let root = temp.path().join(".agents").join("skills");
let skill_dir = root.join("verbose");
fs::create_dir_all(&skill_dir).expect("create skill dir");
fs::write(
skill_dir.join(SKILL_FILE_NAME),
"---\nname: verbose\ndescription: This description is intentionally long\n---\n\n# Verbose\n",
)
.expect("write skill");
let catalog = load_openskills_from_roots(vec![SkillRoot {
path: temp.path().join(".agents").join("skills"),
scope: OpenSkillScope::Project,
}]);
let prompt = catalog.render_prompt_block_with_budget(64).expect("prompt");
assert!(prompt.contains("- verbose: (file:"));
assert!(prompt.contains("descriptions were omitted"));
}
#[test]
fn explicit_dollar_mention_injects_skill_body() {
let temp = tempfile::tempdir().expect("tempdir");
let root = temp.path().join(".agents").join("skills");
let skill_dir = root.join("writer");
fs::create_dir_all(&skill_dir).expect("create skill dir");
fs::write(
skill_dir.join(SKILL_FILE_NAME),
"---\nname: writer\ndescription: Write release notes\n---\n\n# Writer\nUse concise notes.\n",
)
.expect("write skill");
let catalog = load_openskills_from_roots(vec![SkillRoot {
path: root,
scope: OpenSkillScope::Project,
}]);
let injections = catalog.explicit_skill_injections_for_text("please use $writer here");
assert_eq!(injections.len(), 1);
assert_eq!(injections[0].name, "writer");
assert!(injections[0].contents.contains("Use concise notes."));
}
#[test]
fn user_disabled_skill_is_manual_only_but_explicit_mentions_still_work() {
let temp = tempfile::tempdir().expect("tempdir");
let root = temp.path().join(".agents").join("skills");
let skill_dir = root.join("writer");
fs::create_dir_all(&skill_dir).expect("create skill dir");
let skill_path = skill_dir.join(SKILL_FILE_NAME);
fs::write(
&skill_path,
"---\nname: writer\ndescription: Write release notes\n---\n\n# Writer\nUse concise notes.\n",
)
.expect("write skill");
let catalog = load_openskills_from_roots_with_disabled_paths(
vec![SkillRoot {
path: root,
scope: OpenSkillScope::Project,
}],
vec![skill_path],
);
assert_eq!(catalog.skills().len(), 1);
assert!(catalog.skills()[0].user_disabled);
assert!(!catalog.skills()[0].auto_use_enabled());
assert!(catalog.render_prompt_block_with_budget(8_000).is_none());
let injections = catalog.explicit_skill_injections_for_text("please use $writer here");
assert_eq!(injections.len(), 1);
assert!(injections[0].contents.contains("Use concise notes."));
}
#[test]
fn duplicate_skill_names_are_not_injected_by_plain_mention() {
let temp = tempfile::tempdir().expect("tempdir");
let root = temp.path().join(".agents").join("skills");
for dir in ["a", "b"] {
let skill_dir = root.join(dir);
fs::create_dir_all(&skill_dir).expect("create skill dir");
fs::write(
skill_dir.join(SKILL_FILE_NAME),
"---\nname: dup\ndescription: Duplicate name\n---\n\n# Dup\n",
)
.expect("write skill");
}
let catalog = load_openskills_from_roots(vec![SkillRoot {
path: root,
scope: OpenSkillScope::Project,
}]);
let injections = catalog.explicit_skill_injections_for_text("use $dup");
assert!(injections.is_empty());
}
}