use std::path::Path;
use crate::parse::{parse_skill, peek_frontmatter_name, skill_name_matches_directory};
use crate::skill::Skill;
use crate::skip::{SkillSkip, SkipKind};
use crate::source::SkillSource;
use super::DiscoveryOptions;
use super::host_token::{
HostPathField, host_token_collapses_after_whitespace, path_has_line_separator,
skip_line_separator_root, skip_tilde_home_unset, skip_unresolvable_host_path,
skip_whitespace_collapse_token, str_has_line_separator,
};
use super::load::{
child_dir_counts_as_skill_package, dir_load, finish_load_parsed_skill, finish_load_skill_file,
is_skill_md_filename, load_skills_from_dir, read_skill_md, skill_md_inode_exists,
skill_md_is_dir, skill_md_stays_in_package, skip_if_dir_escapes,
skip_if_skill_md_escapes_package, try_load_skill_file, unresolved_symlink_detail,
};
use super::path::{ArgExpand, IgnorePrefix, expand_extra_path_arg, path_is_ignored, stays_under};
pub(super) fn load_extra_path(
raw: &str,
cwd: &Path,
ignore: &[IgnorePrefix],
opts: &DiscoveryOptions,
skills: &mut Vec<Skill>,
skips: &mut Vec<SkillSkip>,
) {
if str_has_line_separator(raw) {
skip_line_separator_root(
Path::new(&crate::sanitize_error_token(raw)),
HostPathField::ExtraPath,
skips,
);
return;
}
if host_token_collapses_after_whitespace(raw) {
skip_whitespace_collapse_token(raw, HostPathField::ExtraPath, skips);
return;
}
let expanded = match expand_extra_path_arg(raw, cwd) {
ArgExpand::Empty => return,
ArgExpand::HomeUnset => {
skip_tilde_home_unset(raw, HostPathField::ExtraPath, skips);
return;
}
ArgExpand::Ready(path) => path,
};
if path_has_line_separator(&expanded) {
skip_line_separator_root(&expanded, HostPathField::ExtraPath, skips);
return;
}
if is_skill_md_filename(&expanded) && skill_md_inode_exists(&expanded) && !expanded.is_dir() {
try_load_skill_file(
&expanded,
&SkillSource::ExtraPath,
ignore,
opts,
&[],
skills,
skips,
);
return;
}
if !expanded.is_dir() {
skip_unresolvable_host_path(&expanded, HostPathField::ExtraPath, skips);
return;
}
let package_md = ["SKILL.md", "skill.md"]
.into_iter()
.map(|name| expanded.join(name))
.find(|p| skill_md_inode_exists(p));
let mut leftover_root = None;
if let Some(skill_file) = package_md.as_ref() {
let classified = classify_extra_path_md(&expanded, skill_file, opts.ascii_names);
match classified {
ExtraPathMd::Collection {
peeked_name,
read_err,
} => {
leftover_root = Some((skill_file.clone(), peeked_name, read_err));
}
other => {
load_classified_extra_path_package(
skill_file,
other,
&SkillSource::ExtraPath,
ignore,
opts,
skills,
skips,
);
return;
}
}
}
let skills_subdir = expanded.join("skills");
let skills_md = ["SKILL.md", "skill.md"]
.into_iter()
.map(|name| skills_subdir.join(name))
.find(|p| skill_md_inode_exists(p));
let mut extra_skills_named = false;
let mut leftover_skills_md = None;
if let Some(skill_file) = skills_md.as_ref() {
let classified = classify_extra_path_md(&skills_subdir, skill_file, opts.ascii_names);
if extra_skills_md_is_named_package(&classified) {
extra_skills_named = true;
load_classified_extra_path_package(
skill_file,
classified,
&SkillSource::ExtraPath,
ignore,
opts,
skills,
skips,
);
} else {
leftover_skills_md = Some(classified);
}
}
let handle_skills = extra_skills_subdir_is_collection(&skills_subdir, &expanded, skips)
&& !extra_skills_named
&& (dir_has_child_skill_packages(&skills_subdir)
|| leftover_skills_md
.as_ref()
.is_some_and(leftover_extra_skills_md_is_collection_entry));
let skip_leftover = leftover_root.as_ref().and_then(|(p, name, err)| {
if handle_skills || !skill_md_is_dir(p) {
skip_loose_extra_path_root_skill_md(
p,
&expanded,
ignore,
name.clone(),
err.clone(),
skips,
);
Some(p.as_path())
} else {
None
}
});
let skip_skills_in_extra_walk =
handle_skills || extra_skills_named || leftover_skills_md.is_some();
if handle_skills {
if let (Some(skill_file), Some(classified)) = (skills_md.as_ref(), leftover_skills_md) {
skip_classified_extra_skills_leftover(
skill_file,
&skills_subdir,
ignore,
classified,
skips,
);
load_skills_from_dir(
&skills_subdir,
&dir_load(&SkillSource::ExtraPath, ignore, opts, &[]),
&[skill_file.as_path()],
skills,
skips,
);
} else {
load_skills_from_dir(
&skills_subdir,
&dir_load(&SkillSource::ExtraPath, ignore, opts, &[]),
&[],
skills,
skips,
);
}
} else if let (Some(skill_file), Some(classified)) = (skills_md.as_ref(), leftover_skills_md) {
skip_classified_extra_skills_leftover(
skill_file,
&skills_subdir,
ignore,
classified,
skips,
);
}
let mut walk_skip = Vec::new();
if let Some(p) = skip_leftover {
walk_skip.push(p);
}
if skip_skills_in_extra_walk {
walk_skip.push(skills_subdir.as_path());
}
load_skills_from_dir(
&expanded,
&dir_load(&SkillSource::ExtraPath, ignore, opts, &[]),
&walk_skip,
skills,
skips,
);
}
pub(super) fn dir_has_child_skill_packages(dir: &Path) -> bool {
let Ok(entries) = std::fs::read_dir(dir) else {
return false;
};
entries.filter_map(Result::ok).any(|entry| {
let path = entry.path();
path.is_dir() && child_dir_counts_as_skill_package(&path)
})
}
pub(super) fn extra_path_has_skills_subdir(dir: &Path) -> bool {
dir.join("skills").is_dir()
}
pub(super) fn extra_should_watch_skills_subdir(dir: &Path, ascii_names: bool) -> bool {
let skills_subdir = dir.join("skills");
if !skills_subdir.is_dir() {
return false;
}
if !stays_under(&skills_subdir, dir) {
return false;
}
if std::fs::read_dir(&skills_subdir).is_err() {
return false;
}
let skills_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) = skills_md.as_ref() {
let classified = classify_extra_path_md(&skills_subdir, skill_file, ascii_names);
if extra_skills_md_is_named_package(&classified) {
return false;
}
if !dir_has_child_skill_packages(&skills_subdir)
&& !leftover_extra_skills_md_is_collection_entry(&classified)
{
return false;
}
} else if !dir_has_child_skill_packages(&skills_subdir) {
return false;
}
let package_md = ["SKILL.md", "skill.md"]
.into_iter()
.map(|name| dir.join(name))
.find(|p| skill_md_inode_exists(p));
match package_md.as_ref() {
Some(skill_file) => extra_path_is_loose_collection(dir, skill_file, ascii_names),
None => true,
}
}
pub(super) fn user_dir_skills_subdir_is_loose_collection(
skills_subdir: &Path,
ascii_names: bool,
) -> bool {
let package_md = ["SKILL.md", "skill.md"]
.into_iter()
.map(|name| skills_subdir.join(name))
.find(|p| skill_md_inode_exists(p));
match package_md.as_ref() {
Some(skill_file) => match classify_extra_path_md(skills_subdir, skill_file, ascii_names) {
ExtraPathMd::Collection { .. } | ExtraPathMd::Unreadable(_) => true,
ExtraPathMd::Package(_) | ExtraPathMd::Parsed(_) | ExtraPathMd::ParseFailed { .. } => {
false
}
},
None => true,
}
}
pub(super) fn user_dir_should_watch_skills_subdir(dir: &Path, ascii_names: bool) -> bool {
let skills_subdir = dir.join("skills");
if !skills_subdir.is_dir() {
return false;
}
if !stays_under(&skills_subdir, dir) {
return false;
}
if std::fs::read_dir(&skills_subdir).is_err() {
return false;
}
user_dir_skills_subdir_is_loose_collection(&skills_subdir, ascii_names)
}
pub(super) fn extra_path_has_skills_entry(dir: &Path) -> bool {
skill_md_inode_exists(&dir.join("skills"))
}
pub(super) fn extra_skills_md_is_named_package(classified: &ExtraPathMd) -> bool {
matches!(
classified,
ExtraPathMd::Parsed(_) | ExtraPathMd::ParseFailed { .. }
)
}
pub(super) fn leftover_extra_skills_md_is_collection_entry(classified: &ExtraPathMd) -> bool {
matches!(classified, ExtraPathMd::Collection { .. })
}
pub(super) fn skip_classified_extra_skills_leftover(
skill_file: &Path,
confine: &Path,
ignore: &[IgnorePrefix],
classified: ExtraPathMd,
skips: &mut Vec<SkillSkip>,
) {
match classified {
ExtraPathMd::Collection {
peeked_name,
read_err,
} => {
skip_loose_extra_path_root_skill_md(
skill_file,
confine,
ignore,
peeked_name,
read_err,
skips,
);
}
ExtraPathMd::Package(content) => {
skip_loose_extra_path_root_skill_md(
skill_file,
confine,
ignore,
peek_frontmatter_name(&content),
None,
skips,
);
}
ExtraPathMd::Unreadable(detail) => {
skip_loose_extra_path_root_skill_md(
skill_file,
confine,
ignore,
None,
Some(detail),
skips,
);
}
ExtraPathMd::Parsed(_) | ExtraPathMd::ParseFailed { .. } => {}
}
}
pub(super) fn extra_skills_subdir_is_collection(
skills_subdir: &Path,
confine: &Path,
skips: &mut Vec<SkillSkip>,
) -> bool {
if !skills_subdir.is_dir() {
return false;
}
if skip_if_dir_escapes(skills_subdir, confine, skips) {
return false;
}
match std::fs::read_dir(skills_subdir) {
Ok(_) => true,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => false,
Err(e) => {
skips.push(SkillSkip {
path: skills_subdir.to_path_buf(),
name: None,
kind: SkipKind::Unreadable,
detail: e.to_string(),
winner_path: None,
host_token: None,
});
false
}
}
}
#[derive(Debug)]
pub(super) enum ExtraPathMd {
Collection {
peeked_name: Option<String>,
read_err: Option<String>,
},
Package(String),
Parsed(Skill),
ParseFailed {
name: Option<String>,
detail: String,
},
Unreadable(String),
}
pub(super) fn extra_path_is_loose_collection(
dir: &Path,
skill_file: &Path,
ascii_names: bool,
) -> bool {
matches!(
classify_extra_path_md(dir, skill_file, ascii_names),
ExtraPathMd::Collection { .. }
)
}
pub(super) fn classify_extra_path_md(
dir: &Path,
skill_file: &Path,
ascii_names: bool,
) -> ExtraPathMd {
if !skill_md_stays_in_package(skill_file) {
if let Some(detail) = unresolved_symlink_detail(skill_file) {
if extra_path_has_skills_subdir(dir) || dir_has_child_skill_packages(dir) {
return ExtraPathMd::Collection {
peeked_name: None,
read_err: Some(detail),
};
}
return ExtraPathMd::Unreadable(detail);
}
if extra_path_has_skills_subdir(dir) || dir_has_child_skill_packages(dir) {
return ExtraPathMd::Collection {
peeked_name: None,
read_err: None,
};
}
return ExtraPathMd::Unreadable("SKILL.md symlink escapes package root".to_owned());
}
let content = match read_skill_md(skill_file) {
Ok(c) => c,
Err(e) => {
if extra_path_has_skills_entry(dir) {
return ExtraPathMd::Collection {
peeked_name: None,
read_err: Some(e),
};
}
if skill_md_is_dir(skill_file) && dir_has_child_skill_packages(dir) {
return ExtraPathMd::Collection {
peeked_name: None,
read_err: Some(e),
};
}
return ExtraPathMd::Unreadable(e);
}
};
let Some(name) = peek_frontmatter_name(&content) else {
return extra_path_collection_signal_or_package(dir, content, None);
};
let normalized = crate::parse::normalize_skill_name(&name);
if normalized.trim().is_empty() {
return extra_path_collection_signal_or_package(dir, content, Some(name));
}
if crate::parse::is_path_component_skill_name(&name) {
return extra_path_collection_signal_or_package(dir, content, Some(name));
}
if skill_name_matches_directory(skill_file, &name) {
if crate::parse::validate_skill_name(&name).is_err() {
return extra_path_collection_signal_or_package(dir, content, Some(name));
}
if ascii_names && !crate::parse::skill_name_is_ascii_policy(&name) {
return extra_path_collection_signal_or_package(dir, content, Some(name));
}
match parse_skill(&content) {
Ok(skill) => return ExtraPathMd::Parsed(skill),
Err(e) => {
if extra_path_has_skills_entry(dir) {
return ExtraPathMd::Collection {
peeked_name: Some(name),
read_err: None,
};
}
return ExtraPathMd::ParseFailed {
name: Some(name),
detail: e.to_string(),
};
}
}
}
if dir_has_child_skill_packages(dir) || extra_path_has_skills_subdir(dir) {
ExtraPathMd::Collection {
peeked_name: Some(name),
read_err: None,
}
} else {
ExtraPathMd::Package(content)
}
}
pub(super) fn extra_path_collection_signal_or_package(
dir: &Path,
content: String,
peeked_name: Option<String>,
) -> ExtraPathMd {
if extra_path_has_skills_entry(dir) {
ExtraPathMd::Collection {
peeked_name,
read_err: None,
}
} else {
ExtraPathMd::Package(content)
}
}
pub(super) fn load_classified_extra_path_package(
skill_file: &Path,
classified: ExtraPathMd,
source: &SkillSource,
ignore: &[IgnorePrefix],
opts: &DiscoveryOptions,
skills: &mut Vec<Skill>,
skips: &mut Vec<SkillSkip>,
) {
if skip_if_skill_md_escapes_package(skill_file, skips) {
return;
}
match classified {
ExtraPathMd::Package(content) => {
if path_is_ignored(skill_file, ignore) {
return;
}
finish_load_skill_file(skill_file, &content, source, opts, &[], skills, skips);
}
ExtraPathMd::Parsed(skill) => {
if path_is_ignored(skill_file, ignore) {
return;
}
finish_load_parsed_skill(skill_file, skill, source, opts, &[], skills, skips);
}
ExtraPathMd::ParseFailed { name, detail } => {
if path_is_ignored(skill_file, ignore) {
return;
}
skips.push(SkillSkip {
path: skill_file.to_path_buf(),
name,
kind: SkipKind::ParseError,
detail,
winner_path: None,
host_token: None,
});
}
ExtraPathMd::Unreadable(detail) => {
if path_is_ignored(skill_file, ignore) {
return;
}
skips.push(SkillSkip {
path: skill_file.to_path_buf(),
name: None,
kind: SkipKind::Unreadable,
detail,
winner_path: None,
host_token: None,
});
}
ExtraPathMd::Collection { .. } => {}
}
}
pub(super) fn skip_loose_extra_path_root_skill_md(
skill_file: &Path,
confine: &Path,
ignore: &[IgnorePrefix],
peeked_name: Option<String>,
read_err: Option<String>,
skips: &mut Vec<SkillSkip>,
) {
if path_is_ignored(skill_file, ignore) {
return;
}
if let Some(detail) = unresolved_symlink_detail(skill_file) {
skips.push(SkillSkip {
path: skill_file.to_path_buf(),
name: None,
kind: SkipKind::Unreadable,
detail,
winner_path: None,
host_token: None,
});
return;
}
if !stays_under(skill_file, confine) {
skips.push(SkillSkip {
path: skill_file.to_path_buf(),
name: None,
kind: SkipKind::Unreadable,
detail: "SKILL.md symlink escapes walk root".to_owned(),
winner_path: None,
host_token: None,
});
return;
}
if let Some(detail) = read_err {
skips.push(SkillSkip {
path: skill_file.to_path_buf(),
name: None,
kind: SkipKind::Unreadable,
detail,
winner_path: None,
host_token: None,
});
return;
}
skips.push(SkillSkip {
path: skill_file.to_path_buf(),
name: peeked_name,
kind: SkipKind::RootFile,
detail: "put the file in a named subdirectory.".to_owned(),
winner_path: None,
host_token: None,
});
}