use std::path::{Component, Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::miss::SkillMiss;
use crate::parse::{
parse_skill, peek_frontmatter_name, skill_md_package_dir_name, skill_name_matches_directory,
unknown_frontmatter_keys,
};
use crate::skill::Skill;
use crate::skip::{DiscoveryReport, SkillSkip, SkipKind};
use crate::source::SkillSource;
use super::CURSOR_VENDOR_DENYLIST;
use super::DiscoveryOptions;
use super::extra_path::{
ExtraPathMd, classify_extra_path_md, extra_should_watch_skills_subdir,
extra_skills_md_is_named_package, extra_skills_subdir_is_collection,
load_classified_extra_path_package, load_extra_path, skip_classified_extra_skills_leftover,
skip_loose_extra_path_root_skill_md, user_dir_should_watch_skills_subdir,
};
use super::host_token::{
HostPathField, component_is_whitespace_padded_dot, host_token_collapses_after_whitespace,
nfkc_dot_path_components, one_line_error, path_has_line_separator,
skip_ignore_tilde_home_unset, skip_line_separator_root, skip_tilde_home_unset,
skip_unresolvable_host_path, skip_whitespace_collapse_token, str_has_line_separator,
};
use super::load::{
dir_load, is_skill_md_filename, load_skills_from_dir, read_skill_md, resolve_validate_target,
skill_md_inode_exists, skip_if_dir_escapes,
};
use super::path::{
ArgExpand, IgnorePrefix, expand_extra_path_arg, expand_ignore_list, expand_user_skills_dir,
implicit_home_already_walked, lexical_normalize, nonempty_home, path_is_ignored, stays_under,
tilde_without_home, walk_cwd_to_git_root,
};
pub fn discover(cwd: &Path, opts: &DiscoveryOptions) -> DiscoveryReport {
discover_report(cwd, opts)
}
pub fn watch_dirs(cwd: &Path, opts: &DiscoveryOptions) -> Vec<PathBuf> {
let cwd = cwd
.canonicalize()
.unwrap_or_else(|_| lexical_normalize(cwd));
let ignore = expand_ignore_list(&cwd, &opts.ignore);
let mut out = Vec::new();
let git_walk = if opts.implicit_roots {
walk_cwd_to_git_root(&cwd)
} else {
Vec::new()
};
if opts.implicit_roots {
for dir in &git_walk {
let agents = dir.join(".agents").join("skills");
if !path_is_ignored(&agents, &ignore) {
push_watch_confined_dir(&mut out, agents, dir);
}
for name in SkillSource::VENDOR_TOKENS.iter().copied() {
if vendor_enabled(opts, name) {
let vendor = dir.join(format!(".{name}")).join("skills");
if !path_is_ignored(&vendor, &ignore) {
push_watch_confined_dir(&mut out, vendor, dir);
}
}
}
}
}
if !opts
.user_skills_dir
.as_deref()
.and_then(|p| p.to_str())
.is_some_and(str_has_line_separator)
{
if let ArgExpand::Ready(user_dir) =
expand_user_skills_dir(&cwd, opts.user_skills_dir.as_deref())
{
if !path_has_line_separator(&user_dir) {
if !path_is_ignored(&user_dir, &ignore) {
push_watch_dir(&mut out, user_dir.clone());
}
let user_skills = user_dir.join("skills");
if user_dir_should_watch_skills_subdir(&user_dir, opts.ascii_names)
&& !path_is_ignored(&user_skills, &ignore)
{
push_watch_dir(&mut out, user_skills);
}
}
}
}
if opts.implicit_roots {
if let Some(home) = nonempty_home() {
if !implicit_home_already_walked(&git_walk, &home) {
let agents = home.join(".agents").join("skills");
if !path_is_ignored(&agents, &ignore) {
push_watch_confined_dir(&mut out, agents, &home);
}
for name in SkillSource::VENDOR_TOKENS.iter().copied() {
if vendor_enabled(opts, name) {
let vendor = home.join(format!(".{name}")).join("skills");
if !path_is_ignored(&vendor, &ignore) {
push_watch_confined_dir(&mut out, vendor, &home);
}
}
}
}
}
}
for raw in &opts.paths {
if str_has_line_separator(raw) || host_token_collapses_after_whitespace(raw) {
continue;
}
let ArgExpand::Ready(expanded) = expand_extra_path_arg(raw, &cwd) else {
continue;
};
if path_has_line_separator(&expanded) {
continue;
}
if path_is_ignored(&expanded, &ignore) {
continue;
}
if is_skill_md_filename(&expanded) && skill_md_inode_exists(&expanded) && !expanded.is_dir()
{
push_watch_file(&mut out, expanded);
continue;
}
if !expanded.is_dir() {
continue;
}
push_watch_dir(&mut out, expanded.clone());
let skills_subdir = expanded.join("skills");
if extra_should_watch_skills_subdir(&expanded, opts.ascii_names)
&& !path_is_ignored(&skills_subdir, &ignore)
{
push_watch_dir(&mut out, skills_subdir);
}
}
out
}
pub fn format_watch_dirs(dirs: &[PathBuf]) -> String {
let mut out = String::new();
for dir in dirs {
out.push_str(&crate::sanitize_error_token(&dir.display().to_string()));
out.push('\n');
}
out
}
pub(super) fn push_watch_dir(out: &mut Vec<PathBuf>, p: PathBuf) {
if p.is_dir() {
push_watch_unique(out, p);
}
}
pub(super) fn push_watch_confined_dir(out: &mut Vec<PathBuf>, p: PathBuf, confine: &Path) {
if p.is_dir() && stays_under(&p, confine) {
push_watch_unique(out, p);
}
}
pub(super) fn push_watch_file(out: &mut Vec<PathBuf>, p: PathBuf) {
if p.is_file() {
push_watch_unique(out, p);
}
}
pub(super) fn push_watch_unique(out: &mut Vec<PathBuf>, p: PathBuf) {
if !out.iter().any(|e| e == &p) {
out.push(p);
}
}
pub fn find_skill_by_name<'a>(skills: &'a [Skill], name: &str) -> Option<&'a Skill> {
if name.trim().is_empty() {
return None;
}
skills
.iter()
.find(|s| crate::parse::skill_names_equal(&s.name, name))
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ValidationReport {
pub path: PathBuf,
pub ok: bool,
pub name: Option<String>,
pub errors: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub skip: Option<SkillSkip>,
}
impl ValidationReport {
pub fn miss(&self) -> Option<SkillMiss> {
if self.ok {
return None;
}
let skip = self.skip.as_ref()?;
let raw = self
.errors
.first()
.map(String::as_str)
.unwrap_or(skip.detail.as_str());
Some(SkillMiss {
error_kind: skip.kind.as_str(),
error: crate::sanitize_error_token(raw),
path: Some(skip.path.clone()),
winner_path: skip.winner_path.clone(),
})
}
}
pub fn validate_path(path: &Path) -> ValidationReport {
validate_path_with_options(path, false)
}
fn anchor_package_path(path: &Path) -> PathBuf {
let candidate = match resolve_validate_target(path) {
Ok(joined) => joined,
Err(_) => path.to_path_buf(),
};
if skill_md_package_dir_name(&candidate).is_some() {
return path.to_path_buf();
}
match std::env::current_dir() {
Ok(cwd) => cwd.join(path),
Err(_) => path.to_path_buf(),
}
}
pub fn validate_path_with_options(path: &Path, strict: bool) -> ValidationReport {
if let Some(detail) = validate_path_hostile_token(path) {
let detail = one_line_error(detail);
let path = PathBuf::from(crate::sanitize_error_token(&path.display().to_string()));
return ValidationReport {
path: path.clone(),
ok: false,
name: None,
errors: vec![detail.clone()],
skip: Some(SkillSkip {
path,
name: None,
kind: SkipKind::Unreadable,
detail,
winner_path: None,
host_token: None,
}),
};
}
let path = nfkc_dot_path_components(path);
let path = anchor_package_path(&path);
let path_buf = match resolve_validate_target(&path) {
Ok(p) => p,
Err(detail) => {
let detail = one_line_error(detail);
return ValidationReport {
path: path.clone(),
ok: false,
name: None,
errors: vec![detail.clone()],
skip: Some(SkillSkip {
path,
name: None,
kind: SkipKind::Unreadable,
detail,
winner_path: None,
host_token: None,
}),
};
}
};
let path = path_buf.as_path();
if let Err(e) = std::fs::metadata(path) {
if e.kind() == std::io::ErrorKind::NotFound || e.kind() == std::io::ErrorKind::NotADirectory
{
let shown = crate::sanitize_error_token(&path.display().to_string());
let detail = format!(
"path does not exist: {shown} (pass a SKILL.md file or a package directory that contains SKILL.md)"
);
let detail = one_line_error(detail);
return ValidationReport {
path: path_buf.clone(),
ok: false,
name: None,
errors: vec![detail.clone()],
skip: Some(SkillSkip {
path: path_buf,
name: None,
kind: SkipKind::Unreadable,
detail,
winner_path: None,
host_token: None,
}),
};
}
}
let content = match read_skill_md(path) {
Ok(c) => c,
Err(e) => {
let detail = one_line_error(e);
return ValidationReport {
path: path_buf.clone(),
ok: false,
name: None,
errors: vec![detail.clone()],
skip: Some(SkillSkip {
path: path_buf,
name: None,
kind: SkipKind::Unreadable,
detail,
winner_path: None,
host_token: None,
}),
};
}
};
match parse_skill(&content) {
Ok(skill) => {
if strict {
let unknown = unknown_frontmatter_keys(&content);
if !unknown.is_empty() {
let shown: Vec<String> = unknown
.iter()
.map(|k| crate::sanitize_error_token(k))
.collect();
let detail = if shown.len() == 1 {
format!(
"unknown frontmatter key: {} (strict mode; drop the key or omit --strict / strict)",
shown[0]
)
} else {
format!(
"unknown frontmatter keys: {} (strict mode; drop the keys or omit --strict / strict)",
shown.join(", ")
)
};
let err = one_line_error(detail);
return ValidationReport {
path: path_buf.clone(),
ok: false,
name: Some(skill.name.clone()),
errors: vec![err.clone()],
skip: Some(SkillSkip {
path: path_buf,
name: Some(skill.name),
kind: SkipKind::ParseError,
detail: err,
winner_path: None,
host_token: None,
}),
};
}
}
if skill_name_matches_directory(path, &skill.name) {
ValidationReport {
path: path_buf,
ok: true,
name: Some(skill.name),
errors: Vec::new(),
skip: None,
}
} else {
let detail = format!(
"frontmatter name `{}` must match parent directory name",
crate::sanitize_error_token(&skill.name)
);
ValidationReport {
path: path_buf.clone(),
ok: false,
name: Some(skill.name.clone()),
errors: vec![detail.clone()],
skip: Some(SkillSkip {
path: path_buf,
name: Some(skill.name),
kind: SkipKind::NameDirectoryMismatch,
detail,
winner_path: None,
host_token: None,
}),
}
}
}
Err(e) => {
let name = peek_frontmatter_name(&content);
let detail = one_line_error(e);
ValidationReport {
path: path_buf.clone(),
ok: false,
name: name.clone(),
errors: vec![detail.clone()],
skip: Some(SkillSkip {
path: path_buf,
name,
kind: SkipKind::ParseError,
detail,
winner_path: None,
host_token: None,
}),
}
}
}
}
pub(super) fn discover_report(cwd: &Path, opts: &DiscoveryOptions) -> DiscoveryReport {
let cwd = cwd
.canonicalize()
.unwrap_or_else(|_| lexical_normalize(cwd));
let ignore = expand_ignore_list(&cwd, &opts.ignore);
let mut skills = Vec::new();
let mut skips = Vec::new();
for raw in &opts.ignore {
if tilde_without_home(raw) {
skip_ignore_tilde_home_unset(raw, &mut skips);
}
}
let git_walk = if opts.implicit_roots {
walk_cwd_to_git_root(&cwd)
} else {
Vec::new()
};
if opts.implicit_roots {
for dir in &git_walk {
let agents = dir.join(".agents").join("skills");
if !skip_if_dir_escapes(&agents, dir, &mut skips) {
load_skills_from_dir(
&agents,
&dir_load(&SkillSource::Agents, &ignore, opts, &[]),
&[],
&mut skills,
&mut skips,
);
}
load_vendor_tree(dir, opts, &ignore, &mut skills, &mut skips);
}
}
if let Some(raw_path) = opts.user_skills_dir.as_deref() {
if raw_path.to_str().is_some_and(str_has_line_separator) {
skip_line_separator_root(raw_path, HostPathField::UserDir, &mut skips);
} else if raw_path
.to_str()
.is_some_and(host_token_collapses_after_whitespace)
{
if let Some(raw) = raw_path.to_str() {
skip_whitespace_collapse_token(raw, HostPathField::UserDir, &mut skips);
}
} else if let ArgExpand::HomeUnset = expand_user_skills_dir(&cwd, Some(raw_path)) {
if let Some(raw) = raw_path.to_str() {
skip_tilde_home_unset(raw, HostPathField::UserDir, &mut skips);
}
} else if let ArgExpand::Ready(user_dir) = expand_user_skills_dir(&cwd, Some(raw_path)) {
if path_has_line_separator(&user_dir) {
skip_line_separator_root(&user_dir, HostPathField::UserDir, &mut skips);
} else if !user_dir.is_dir() {
skip_unresolvable_host_path(&user_dir, HostPathField::UserDir, &mut skips);
} else {
let skills_subdir = user_dir.join("skills");
let handle_skills =
extra_skills_subdir_is_collection(&skills_subdir, &user_dir, &mut skips);
let skills_ref: &Path = &skills_subdir;
let skip_skills = [skills_ref];
let user_skip_skills: &[&Path] = if handle_skills { &skip_skills } else { &[] };
load_skills_from_dir(
&user_dir,
&dir_load(&SkillSource::User, &ignore, opts, &[]),
user_skip_skills,
&mut skills,
&mut skips,
);
if handle_skills {
let package_md = ["SKILL.md", "skill.md"]
.into_iter()
.map(|name| skills_subdir.join(name))
.find(|p| skill_md_inode_exists(p));
if let Some(skill_file) = package_md {
let classified =
classify_extra_path_md(&skills_subdir, &skill_file, opts.ascii_names);
match classified {
ExtraPathMd::Collection {
peeked_name,
read_err,
} => {
skip_loose_extra_path_root_skill_md(
&skill_file,
&skills_subdir,
&ignore,
peeked_name,
read_err,
&mut skips,
);
load_skills_from_dir(
&skills_subdir,
&dir_load(&SkillSource::User, &ignore, opts, &[]),
&[skill_file.as_path()],
&mut skills,
&mut skips,
);
}
ExtraPathMd::Unreadable(detail) => {
skip_loose_extra_path_root_skill_md(
&skill_file,
&skills_subdir,
&ignore,
None,
Some(detail),
&mut skips,
);
load_skills_from_dir(
&skills_subdir,
&dir_load(&SkillSource::User, &ignore, opts, &[]),
&[skill_file.as_path()],
&mut skills,
&mut skips,
);
}
other => {
if extra_skills_md_is_named_package(&other) {
load_classified_extra_path_package(
&skill_file,
other,
&SkillSource::User,
&ignore,
opts,
&mut skills,
&mut skips,
);
} else {
skip_classified_extra_skills_leftover(
&skill_file,
&skills_subdir,
&ignore,
other,
&mut skips,
);
load_skills_from_dir(
&skills_subdir,
&dir_load(&SkillSource::User, &ignore, opts, &[]),
&[skill_file.as_path()],
&mut skills,
&mut skips,
);
}
}
}
} else {
load_skills_from_dir(
&skills_subdir,
&dir_load(&SkillSource::User, &ignore, opts, &[]),
&[],
&mut skills,
&mut skips,
);
}
}
}
}
}
if opts.implicit_roots {
if let Some(home) = nonempty_home() {
if !implicit_home_already_walked(&git_walk, &home) {
let agents = home.join(".agents").join("skills");
if !skip_if_dir_escapes(&agents, &home, &mut skips) {
load_skills_from_dir(
&agents,
&dir_load(&SkillSource::Agents, &ignore, opts, &[]),
&[],
&mut skills,
&mut skips,
);
}
load_vendor_tree(&home, opts, &ignore, &mut skills, &mut skips);
}
}
}
for raw in &opts.paths {
if raw.trim().is_empty() {
continue;
}
load_extra_path(raw, &cwd, &ignore, opts, &mut skills, &mut skips);
}
DiscoveryReport { skills, skips }
}
pub(super) fn load_vendor_tree(
root: &Path,
opts: &DiscoveryOptions,
ignore: &[IgnorePrefix],
skills: &mut Vec<Skill>,
skips: &mut Vec<SkillSkip>,
) {
for name in SkillSource::VENDOR_TOKENS.iter().copied() {
if !vendor_enabled(opts, name) {
continue;
}
let dir = root.join(format!(".{name}")).join("skills");
if skip_if_dir_escapes(&dir, root, skips) {
continue;
}
let denylist: &[&str] = if name == "cursor" {
CURSOR_VENDOR_DENYLIST
} else {
&[]
};
let source = SkillSource::Vendor {
name: name.to_owned(),
};
load_skills_from_dir(
&dir,
&dir_load(&source, ignore, opts, denylist),
&[],
skills,
skips,
);
}
}
pub(super) fn vendor_enabled(opts: &DiscoveryOptions, name: &str) -> bool {
opts.vendor_roots.iter().any(|v| v == name)
}
pub(super) fn validate_path_hostile_token(path: &Path) -> Option<&'static str> {
if path.to_str().is_some_and(str_has_line_separator) || path_has_line_separator(path) {
return Some(HostPathField::Validate.line_sep_detail());
}
if path
.to_str()
.is_some_and(host_token_collapses_after_whitespace)
{
return Some(HostPathField::Validate.collapse_detail());
}
let padded = path.components().any(|c| match c {
Component::Normal(s) => s.to_str().is_some_and(component_is_whitespace_padded_dot),
_ => false,
});
if padded {
return Some(HostPathField::Validate.collapse_detail());
}
None
}