mirror of
https://github.com/zed-industries/zed.git
synced 2026-06-01 03:14:56 +07:00
Closes AI-266 This PR adds a built-in skill called `create-skill`, which allows the Zed agent to have access to a skill that teaches it how to properly create skills for Zed. You can manually invoke it as well as just letting the model auto-invoke it in case your prompt suggests creating a new skill. Release Notes: - Agent: Added a built-in skill called `create-skill` to make the Zed agent informed about how to do that. --------- Co-authored-by: Richard Feldman <richard@zed.dev>
1876 lines
63 KiB
Rust
1876 lines
63 KiB
Rust
use anyhow::{Context as _, Result};
|
||
use const_format::concatcp;
|
||
use fs::Fs;
|
||
use futures::StreamExt;
|
||
use serde::{Deserialize, Serialize};
|
||
use std::io::{self, Read};
|
||
use std::path::{Path, PathBuf};
|
||
use std::sync::Arc;
|
||
use util::paths::component_matches_ignore_ascii_case;
|
||
|
||
/// First segment of the skills directory path: `.agents`.
|
||
pub const AGENTS_DIR_NAME: &str = ".agents";
|
||
|
||
/// Second segment of the skills directory path: `skills`.
|
||
pub const SKILLS_DIR_NAME: &str = "skills";
|
||
|
||
/// User-facing display form of the global skills directory path — i.e.
|
||
/// what a human should see in messages and prompts, with the platform's
|
||
/// native path separator and home-directory shorthand.
|
||
///
|
||
/// Windows doesn't recognize `~` as the home directory, so the env-var
|
||
/// form is used there instead.
|
||
#[cfg(target_os = "windows")]
|
||
pub const GLOBAL_SKILLS_DIR_DISPLAY: &str =
|
||
concatcp!("%USERPROFILE%\\", AGENTS_DIR_NAME, "\\", SKILLS_DIR_NAME);
|
||
#[cfg(not(target_os = "windows"))]
|
||
pub const GLOBAL_SKILLS_DIR_DISPLAY: &str = concatcp!("~/", AGENTS_DIR_NAME, "/", SKILLS_DIR_NAME);
|
||
|
||
/// Opaque identifier for the project scope a skill was loaded from.
|
||
///
|
||
/// `agent_skills` is a leaf crate and intentionally does not depend on
|
||
/// `worktree`. Callers (e.g. the `agent` crate) construct these from
|
||
/// `worktree::WorktreeId::to_usize()` and recover the original ID via
|
||
/// `worktree::WorktreeId::from_usize()` when needed.
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||
pub struct SkillScopeId(pub usize);
|
||
|
||
/// Cap on concurrent filesystem operations during skill discovery and loading.
|
||
/// Without this bound, a `.agents/skills` directory containing thousands of
|
||
/// entries would fan out an equally large number of concurrent OS-level I/O
|
||
/// operations, potentially exhausting file descriptors or stalling the app.
|
||
const SKILL_IO_CONCURRENCY: usize = 16;
|
||
|
||
/// Maximum size for a single SKILL.md file (100KB)
|
||
pub const MAX_SKILL_FILE_SIZE: usize = 100 * 1024;
|
||
|
||
/// Maximum total size for skill descriptions in system prompt (50KB)
|
||
pub const MAX_SKILL_DESCRIPTIONS_SIZE: usize = 50 * 1024;
|
||
|
||
/// The name of the skill definition file
|
||
pub const SKILL_FILE_NAME: &str = "SKILL.md";
|
||
|
||
/// Represents a loaded skill with all its metadata and content.
|
||
#[derive(Debug, Clone)]
|
||
pub struct Skill {
|
||
pub name: String,
|
||
pub description: String,
|
||
pub source: SkillSource,
|
||
/// Absolute path to the skill directory
|
||
pub directory_path: PathBuf,
|
||
/// Absolute path to the SKILL.md file
|
||
pub skill_file_path: PathBuf,
|
||
/// When `true`, this skill is hidden from the model's catalog and the
|
||
/// `skill` tool refuses to load it. The user can still invoke it as a
|
||
/// slash command.
|
||
pub disable_model_invocation: bool,
|
||
/// For built-in skills whose content is compiled into the binary,
|
||
/// this holds the full SKILL.md body so the skill tool can serve it
|
||
/// without a filesystem read.
|
||
pub embedded_body: Option<&'static str>,
|
||
}
|
||
|
||
/// Indicates where a skill was loaded from.
|
||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||
pub enum SkillSource {
|
||
/// Compiled into the Zed binary. These are always available and have
|
||
/// the lowest override priority (global and project-local skills can
|
||
/// shadow them).
|
||
BuiltIn,
|
||
/// From ~/.agents/skills/
|
||
Global,
|
||
/// From {project}/.agents/skills/
|
||
ProjectLocal {
|
||
worktree_id: SkillScopeId,
|
||
worktree_root_name: Arc<str>,
|
||
},
|
||
}
|
||
|
||
impl SkillSource {
|
||
/// Precedence for resolving same-named skills. Higher values shadow
|
||
/// lower ones: `ProjectLocal` > `Global` > `BuiltIn`. Two sources
|
||
/// returning equal precedence (e.g. two project-local skills from
|
||
/// different worktrees) leave the winner up to the caller, which by
|
||
/// convention keeps the first one in iteration order.
|
||
///
|
||
/// Adding a new `SkillSource` variant should be a one-line change
|
||
/// here — every consumer routes through this method so the hierarchy
|
||
/// stays in sync.
|
||
pub fn precedence(&self) -> u8 {
|
||
match self {
|
||
Self::BuiltIn => 0,
|
||
Self::Global => 1,
|
||
Self::ProjectLocal { .. } => 2,
|
||
}
|
||
}
|
||
|
||
/// Scope prefix used in the `/<prefix>:<name>` slash-command
|
||
/// syntax that the autocomplete popup inserts. Global skills use
|
||
/// an empty prefix (so the inserted text is `/:<name>`), and
|
||
/// project-local skills use their worktree root name (so the
|
||
/// inserted text is `/<worktree>:<name>`).
|
||
///
|
||
/// Using an empty prefix for globals rather than a literal
|
||
/// `global` means a worktree literally named `global` is no
|
||
/// longer ambiguous with the global source: the global skill is
|
||
/// invoked as `/:<name>`, and the worktree's skill is invoked as
|
||
/// `/global:<name>`. The two grammars never collide on the
|
||
/// inserted text.
|
||
/// Human-readable label for this source, used in the UI to
|
||
/// distinguish skills from different origins.
|
||
pub fn display_label(&self) -> &str {
|
||
match self {
|
||
Self::BuiltIn => "built-in",
|
||
Self::Global => "global",
|
||
Self::ProjectLocal {
|
||
worktree_root_name, ..
|
||
} => worktree_root_name.as_ref(),
|
||
}
|
||
}
|
||
|
||
pub fn scope_prefix(&self) -> &str {
|
||
match self {
|
||
Self::BuiltIn | Self::Global => "",
|
||
Self::ProjectLocal {
|
||
worktree_root_name, ..
|
||
} => worktree_root_name.as_ref(),
|
||
}
|
||
}
|
||
|
||
/// Whether this source matches the given scope qualifier from a
|
||
/// `/<scope>:<name>` slash command. The empty scope is reserved
|
||
/// for global skills; non-empty scopes match a project-local
|
||
/// skill whose worktree root name equals the scope.
|
||
///
|
||
/// Hand-typed `/global:<name>` is NOT treated as an alias for
|
||
/// `/:<name>`. It looks for a project-local skill from a worktree
|
||
/// named `global` and fails if none exists. The popup always
|
||
/// inserts the unambiguous form (`/:<name>` for globals), so this
|
||
/// strictness only affects users typing by memory.
|
||
pub fn matches_scope(&self, scope: &str) -> bool {
|
||
match self {
|
||
Self::BuiltIn | Self::Global => scope.is_empty(),
|
||
Self::ProjectLocal {
|
||
worktree_root_name, ..
|
||
} => !scope.is_empty() && worktree_root_name.as_ref() == scope,
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Just the frontmatter, used for parsing
|
||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||
pub struct SkillMetadata {
|
||
pub name: String,
|
||
pub description: String,
|
||
#[serde(default, rename = "disable-model-invocation")]
|
||
pub disable_model_invocation: bool,
|
||
}
|
||
|
||
/// Minimal skill info for system prompt (not full content).
|
||
///
|
||
/// `Serialize` is required for handlebars rendering of the system prompt
|
||
/// template (see `ProjectContext` in `prompt_store`). `PartialEq, Eq` lets
|
||
/// the agent compare freshly-built `ProjectContext`s and skip pushing an
|
||
/// unchanged value through the project_context entity (which would
|
||
/// otherwise look like a system-prompt change to the model and invalidate
|
||
/// the API's prompt cache).
|
||
#[derive(Debug, Clone, Eq, PartialEq, Serialize)]
|
||
pub struct SkillSummary {
|
||
pub name: String,
|
||
pub description: String,
|
||
/// Absolute path to the SKILL.md file, so the model can resolve
|
||
/// references relative to the skill's directory when reading bundled
|
||
/// resources.
|
||
pub location: String,
|
||
}
|
||
|
||
impl From<&Skill> for SkillSummary {
|
||
fn from(skill: &Skill) -> Self {
|
||
Self {
|
||
name: skill.name.clone(),
|
||
description: skill.description.clone(),
|
||
location: skill.skill_file_path.to_string_lossy().into_owned(),
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Error that occurred while loading a skill
|
||
#[derive(Debug, Clone)]
|
||
pub struct SkillLoadError {
|
||
pub path: PathBuf,
|
||
pub message: String,
|
||
}
|
||
|
||
impl std::fmt::Display for SkillLoadError {
|
||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||
write!(f, "{}: {}", self.path.display(), self.message)
|
||
}
|
||
}
|
||
|
||
impl std::error::Error for SkillLoadError {}
|
||
|
||
/// Parse the frontmatter of a SKILL.md file into a `Skill` struct.
|
||
///
|
||
/// The file must have YAML frontmatter between `---` delimiters containing
|
||
/// `name` and `description` fields. The body (everything after the closing
|
||
/// `---`) is intentionally NOT returned — it's read on demand via
|
||
/// `read_skill_body` when the skill is actually being materialized for the
|
||
/// model, so we don't pay N × body-size in memory for N skills.
|
||
///
|
||
/// `content` only needs to contain bytes up through the closing `---`; any
|
||
/// trailing body bytes are ignored.
|
||
pub fn parse_skill_frontmatter(
|
||
skill_file_path: &Path,
|
||
content: &str,
|
||
source: SkillSource,
|
||
) -> Result<Skill> {
|
||
if content.len() > MAX_SKILL_FILE_SIZE {
|
||
anyhow::bail!(
|
||
"SKILL.md file exceeds maximum size of {}KB",
|
||
MAX_SKILL_FILE_SIZE / 1024
|
||
);
|
||
}
|
||
|
||
let (metadata, _body) = extract_frontmatter(content)?;
|
||
|
||
validate_name(&metadata.name)?;
|
||
validate_description(&metadata.description)?;
|
||
|
||
let directory_path = skill_file_path
|
||
.parent()
|
||
.context("SKILL.md file has no parent directory")?
|
||
.to_path_buf();
|
||
|
||
Ok(Skill {
|
||
name: metadata.name,
|
||
description: metadata.description,
|
||
source,
|
||
directory_path,
|
||
skill_file_path: skill_file_path.to_path_buf(),
|
||
disable_model_invocation: metadata.disable_model_invocation,
|
||
embedded_body: None,
|
||
})
|
||
}
|
||
|
||
fn extract_frontmatter(content: &str) -> Result<(SkillMetadata, &str)> {
|
||
let content = content.trim_start();
|
||
|
||
if !content.starts_with("---") {
|
||
anyhow::bail!("SKILL.md must start with YAML frontmatter (---)");
|
||
}
|
||
|
||
// Find every candidate closing `---` line: a line consisting EXACTLY of
|
||
// `---` (followed by `\n`, `\r\n`, or EOF) at column 0, excluding the
|
||
// opening line itself. The opener occupies bytes 0..(first line ending),
|
||
// and our scan starts after each `\n`, so the opener is naturally skipped.
|
||
//
|
||
// For each candidate we record the byte position right after its line
|
||
// ending; that's both where the YAML stream slice ends and where the body
|
||
// begins.
|
||
let bytes = content.as_bytes();
|
||
let mut candidates: Vec<usize> = Vec::new();
|
||
for (i, &b) in bytes.iter().enumerate() {
|
||
if b != b'\n' {
|
||
continue;
|
||
}
|
||
let line_start = i + 1;
|
||
if line_start + 3 > bytes.len() {
|
||
continue;
|
||
}
|
||
if &bytes[line_start..line_start + 3] != b"---" {
|
||
continue;
|
||
}
|
||
let after_dashes = line_start + 3;
|
||
let end = if after_dashes == bytes.len() {
|
||
after_dashes
|
||
} else if bytes[after_dashes] == b'\n' {
|
||
after_dashes + 1
|
||
} else if after_dashes + 1 < bytes.len()
|
||
&& bytes[after_dashes] == b'\r'
|
||
&& bytes[after_dashes + 1] == b'\n'
|
||
{
|
||
after_dashes + 2
|
||
} else {
|
||
// Line is something like `---trailing` or `----`; not a candidate.
|
||
continue;
|
||
};
|
||
candidates.push(end);
|
||
}
|
||
|
||
if candidates.is_empty() {
|
||
anyhow::bail!("SKILL.md missing closing frontmatter delimiter (---)");
|
||
}
|
||
|
||
// Try each candidate in order: slice content up through the candidate's
|
||
// terminator and ask `serde_yaml_ng` to parse it as a YAML stream. If the
|
||
// first document deserializes into `SkillMetadata`, that candidate is the
|
||
// real closer. Otherwise an earlier candidate may have cut the YAML in the
|
||
// middle of a scalar / quoted string; try the next one.
|
||
let mut last_error: Option<anyhow::Error> = None;
|
||
for end in candidates {
|
||
let prefix = &content[..end];
|
||
let mut docs = serde_yaml_ng::Deserializer::from_str(prefix);
|
||
let Some(first_doc) = docs.next() else {
|
||
continue;
|
||
};
|
||
match SkillMetadata::deserialize(first_doc) {
|
||
Ok(metadata) => return Ok((metadata, &content[end..])),
|
||
Err(e) => last_error = Some(anyhow::Error::new(e)),
|
||
}
|
||
}
|
||
|
||
Err(last_error
|
||
.unwrap_or_else(|| anyhow::anyhow!("could not parse YAML frontmatter"))
|
||
.context("Invalid YAML frontmatter"))
|
||
}
|
||
|
||
/// Maximum length for a valid skill name. Mirrors the upper bound enforced
|
||
/// by [`validate_name`].
|
||
pub const MAX_SKILL_NAME_LEN: usize = 64;
|
||
|
||
/// Convert an arbitrary human-readable string into a valid skill name, or
|
||
/// return `None` if no valid name can be produced (e.g. the input contains
|
||
/// no ASCII alphanumeric characters at all).
|
||
///
|
||
/// The transformation:
|
||
///
|
||
/// 1. Replaces each `&` with the word `and` (with separators on either
|
||
/// side), so titles like "rock & roll" or "AT&T" round-trip something
|
||
/// meaningful (`rock-and-roll`, `at-and-t`) rather than dropping the
|
||
/// `&` and silently mashing the neighbours together.
|
||
/// 2. ASCII-lowercases every ASCII letter.
|
||
/// 3. Replaces each space with `-`. Existing `-` characters are kept.
|
||
/// 4. **Drops** every other non-alphanumeric character entirely (NOT
|
||
/// replaced with a dash). So `foo!bar` slugifies to `foobar`, not
|
||
/// `foo-bar` — only word boundaries the user actually wrote (spaces)
|
||
/// become dashes.
|
||
/// 5. Collapses runs of `-` into a single `-`.
|
||
/// 6. Trims leading and trailing `-`.
|
||
/// 7. Truncates to [`MAX_SKILL_NAME_LEN`] bytes (then re-trims trailing `-`
|
||
/// in case the truncation landed on one).
|
||
///
|
||
/// The result, if `Some`, always satisfies [`validate_name`].
|
||
pub fn slugify_skill_name(input: &str) -> Option<String> {
|
||
// Substitute `&` with `-and-` BEFORE the per-character pass; the
|
||
// existing dash-collapsing and edge-trimming logic then handles the
|
||
// boundary cases (`foo & bar`, `&foo`, `foo&`, `&&`, etc.) for free.
|
||
let input = input.replace('&', "-and-");
|
||
let mut slug = String::with_capacity(input.len());
|
||
let mut last_was_dash = true; // suppress a leading `-`
|
||
for ch in input.chars() {
|
||
let mapped = if ch.is_ascii_alphanumeric() {
|
||
Some(ch.to_ascii_lowercase())
|
||
} else if ch == ' ' || ch == '-' {
|
||
Some('-')
|
||
} else {
|
||
// Drop the character entirely — and importantly, do NOT touch
|
||
// `last_was_dash`. That way `foo!bar` stays one run of
|
||
// alphanumerics (`foobar`) rather than getting a fake
|
||
// separator inserted (`foo-bar`).
|
||
None
|
||
};
|
||
let Some(c) = mapped else { continue };
|
||
if c == '-' {
|
||
if last_was_dash {
|
||
continue;
|
||
}
|
||
last_was_dash = true;
|
||
} else {
|
||
last_was_dash = false;
|
||
}
|
||
slug.push(c);
|
||
}
|
||
if slug.ends_with('-') {
|
||
slug.pop();
|
||
}
|
||
if slug.len() > MAX_SKILL_NAME_LEN {
|
||
slug.truncate(MAX_SKILL_NAME_LEN);
|
||
while slug.ends_with('-') {
|
||
slug.pop();
|
||
}
|
||
}
|
||
if slug.is_empty() { None } else { Some(slug) }
|
||
}
|
||
|
||
fn validate_name(name: &str) -> Result<()> {
|
||
if name.is_empty() {
|
||
anyhow::bail!("Skill name cannot be empty");
|
||
}
|
||
|
||
if name.len() > MAX_SKILL_NAME_LEN {
|
||
anyhow::bail!("Skill name must be at most {MAX_SKILL_NAME_LEN} characters");
|
||
}
|
||
|
||
if !name
|
||
.chars()
|
||
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
|
||
{
|
||
anyhow::bail!("Skill name must contain only lowercase letters, numbers, and hyphens");
|
||
}
|
||
|
||
Ok(())
|
||
}
|
||
|
||
fn validate_description(description: &str) -> Result<()> {
|
||
if description.is_empty() {
|
||
anyhow::bail!("Skill description cannot be empty");
|
||
}
|
||
|
||
if description.len() > 1024 {
|
||
anyhow::bail!("Skill description must be at most 1024 characters");
|
||
}
|
||
|
||
Ok(())
|
||
}
|
||
|
||
pub async fn load_skills_from_directory(
|
||
fs: &Arc<dyn Fs>,
|
||
directory: &Path,
|
||
source: SkillSource,
|
||
) -> Vec<Result<Skill, SkillLoadError>> {
|
||
if !fs.is_dir(directory).await {
|
||
return Vec::new();
|
||
}
|
||
|
||
let skill_files = find_skill_files(fs, directory).await;
|
||
|
||
let mut results: Vec<Result<Skill, SkillLoadError>> = futures::stream::iter(skill_files)
|
||
.map(|path| {
|
||
let fs = fs.clone();
|
||
let source = source.clone();
|
||
async move { load_skill_frontmatter(fs, path, source).await }
|
||
})
|
||
.buffer_unordered(SKILL_IO_CONCURRENCY)
|
||
.collect()
|
||
.await;
|
||
|
||
// Sort by path so name-conflict resolution in `apply_skill_overrides`
|
||
// is deterministic — `fs.read_dir` order is filesystem-dependent.
|
||
results.sort_by(|a, b| {
|
||
let path_a: &Path = match a {
|
||
Ok(skill) => &skill.skill_file_path,
|
||
Err(error) => &error.path,
|
||
};
|
||
let path_b: &Path = match b {
|
||
Ok(skill) => &skill.skill_file_path,
|
||
Err(error) => &error.path,
|
||
};
|
||
path_a.cmp(path_b)
|
||
});
|
||
|
||
results
|
||
}
|
||
|
||
/// Find every `<skills_root>/<name>/SKILL.md` directly under `directory`.
|
||
///
|
||
/// Discovery is intentionally one level deep: a skill is the immediate
|
||
/// child directory of the skills root, and `SKILL.md` is the file that
|
||
/// names it. See `crates/agent_skills/README.md` for why we don't recurse.
|
||
async fn find_skill_files(fs: &Arc<dyn Fs>, directory: &Path) -> Vec<PathBuf> {
|
||
let Ok(mut entries) = fs.read_dir(directory).await else {
|
||
return Vec::new();
|
||
};
|
||
|
||
let mut entry_paths = Vec::new();
|
||
while let Some(entry) = entries.next().await {
|
||
if let Ok(entry_path) = entry {
|
||
entry_paths.push(entry_path);
|
||
}
|
||
}
|
||
|
||
futures::stream::iter(entry_paths)
|
||
.map(|entry_path| {
|
||
let fs = fs.clone();
|
||
async move {
|
||
let Ok(Some(metadata)) = fs.metadata(&entry_path).await else {
|
||
return None;
|
||
};
|
||
if !metadata.is_dir {
|
||
return None;
|
||
}
|
||
let skill_file = entry_path.join(SKILL_FILE_NAME);
|
||
fs.is_file(&skill_file).await.then_some(skill_file)
|
||
}
|
||
})
|
||
.buffer_unordered(SKILL_IO_CONCURRENCY)
|
||
.filter_map(|x| async move { x })
|
||
.collect()
|
||
.await
|
||
}
|
||
|
||
/// Returns the byte index ONE PAST the end of the closing frontmatter
|
||
/// delimiter line in `bytes`, or `None` if no closing delimiter has been
|
||
/// seen yet. Used by the chunked reader to know when it has enough
|
||
/// bytes to stop pulling from disk.
|
||
///
|
||
/// Scans for the first `\n---` line followed by `\n`, `\r\n`, or EOF
|
||
/// (excluding the opening line itself, which sits at byte 0 and is
|
||
/// naturally skipped because we only consider lines following a `\n`).
|
||
/// This may overshoot in pathological cases (e.g. `---` inside a quoted
|
||
/// YAML string), but `parse_skill_frontmatter`'s candidate-and-validate
|
||
/// logic still produces a correct result or a YAML parse error.
|
||
fn closing_delimiter_end(bytes: &[u8]) -> Option<usize> {
|
||
for (i, &b) in bytes.iter().enumerate() {
|
||
if b != b'\n' {
|
||
continue;
|
||
}
|
||
let line_start = i + 1;
|
||
if line_start + 3 > bytes.len() {
|
||
continue;
|
||
}
|
||
if &bytes[line_start..line_start + 3] != b"---" {
|
||
continue;
|
||
}
|
||
let after_dashes = line_start + 3;
|
||
if after_dashes == bytes.len() {
|
||
return Some(after_dashes);
|
||
}
|
||
if bytes[after_dashes] == b'\n' {
|
||
return Some(after_dashes + 1);
|
||
}
|
||
if after_dashes + 1 < bytes.len()
|
||
&& bytes[after_dashes] == b'\r'
|
||
&& bytes[after_dashes + 1] == b'\n'
|
||
{
|
||
return Some(after_dashes + 2);
|
||
}
|
||
// Line is `---trailing` or `----`; keep scanning.
|
||
}
|
||
None
|
||
}
|
||
|
||
/// Read just enough of `skill_file_path` from disk to parse its
|
||
/// frontmatter. The SKILL.md body is NOT loaded — that's deferred to
|
||
/// `read_skill_body`, called only when a skill is actually being
|
||
/// materialized for the model. Reading in 4KB chunks keeps the peak
|
||
/// memory cost of loading N skills proportional to total frontmatter
|
||
/// size, not total file size.
|
||
pub async fn load_skill_frontmatter(
|
||
fs: Arc<dyn Fs>,
|
||
skill_file_path: PathBuf,
|
||
source: SkillSource,
|
||
) -> Result<Skill, SkillLoadError> {
|
||
// Short-circuit on oversized files before reading any of their
|
||
// contents, so a stray multi-GB file named `SKILL.md` can't OOM the
|
||
// app. We only act on a positive signal that the file is too large;
|
||
// if metadata fails or is unavailable, we fall through to the read
|
||
// loop, which is itself capped at `MAX_SKILL_FILE_SIZE`.
|
||
if let Ok(Some(metadata)) = fs.metadata(&skill_file_path).await
|
||
&& metadata.len > MAX_SKILL_FILE_SIZE as u64
|
||
{
|
||
return Err(SkillLoadError {
|
||
path: skill_file_path.clone(),
|
||
message: format!(
|
||
"SKILL.md file exceeds maximum size of {}KB",
|
||
MAX_SKILL_FILE_SIZE / 1024
|
||
),
|
||
});
|
||
}
|
||
|
||
let mut reader = fs
|
||
.open_sync(&skill_file_path)
|
||
.await
|
||
.map_err(|e| SkillLoadError {
|
||
path: skill_file_path.clone(),
|
||
message: format!("Failed to open file: {}", e),
|
||
})?;
|
||
|
||
// The chunked read is intentionally synchronous: `Fs::open_sync`
|
||
// returns a synchronous `Read` (RealFs uses `std::fs::File`), and
|
||
// production callers already wrap `load_skills_from_directory` in
|
||
// `cx.background_spawn`, so any blocking happens on the background
|
||
// executor — not on the foreground thread. Routing through
|
||
// `smol::unblock` instead would schedule the work on smol's blocking
|
||
// pool, whose wakeups don't drive GPUI's test scheduler and therefore
|
||
// panic with "Parking forbidden" under `TestAppContext`.
|
||
let read_result: Result<Vec<u8>, io::Error> = (|| {
|
||
let mut accumulated: Vec<u8> = Vec::new();
|
||
let mut chunk = [0u8; 4096];
|
||
loop {
|
||
let n = reader.read(&mut chunk)?;
|
||
if n == 0 {
|
||
break;
|
||
}
|
||
accumulated.extend_from_slice(&chunk[..n]);
|
||
if closing_delimiter_end(&accumulated).is_some() {
|
||
break;
|
||
}
|
||
if accumulated.len() > MAX_SKILL_FILE_SIZE {
|
||
break;
|
||
}
|
||
}
|
||
Ok(accumulated)
|
||
})();
|
||
let accumulated = read_result.map_err(|e| SkillLoadError {
|
||
path: skill_file_path.clone(),
|
||
message: format!("Failed to read file: {}", e),
|
||
})?;
|
||
|
||
let content = std::str::from_utf8(&accumulated).map_err(|e| SkillLoadError {
|
||
path: skill_file_path.clone(),
|
||
message: format!("SKILL.md is not valid UTF-8: {}", e),
|
||
})?;
|
||
|
||
parse_skill_frontmatter(&skill_file_path, content, source).map_err(|e| SkillLoadError {
|
||
path: skill_file_path.clone(),
|
||
message: e.to_string(),
|
||
})
|
||
}
|
||
|
||
/// Read the body of a SKILL.md from disk — everything after the closing
|
||
/// `---`. Called only when a skill is being materialized for the model
|
||
/// (via `SkillTool` or a slash invocation). The body is intentionally
|
||
/// NOT kept in memory between materializations.
|
||
pub async fn read_skill_body(
|
||
fs: &dyn Fs,
|
||
skill_file_path: &Path,
|
||
) -> Result<String, SkillLoadError> {
|
||
let content = fs.load(skill_file_path).await.map_err(|e| SkillLoadError {
|
||
path: skill_file_path.to_path_buf(),
|
||
message: format!("Failed to read file: {}", e),
|
||
})?;
|
||
|
||
let (_metadata, body) = extract_frontmatter(&content).map_err(|e| SkillLoadError {
|
||
path: skill_file_path.to_path_buf(),
|
||
message: e.to_string(),
|
||
})?;
|
||
|
||
Ok(body.trim().to_string())
|
||
}
|
||
|
||
/// Content of the built-in `create-skill` SKILL.md, embedded at compile time.
|
||
const CREATE_SKILL_CONTENT: &str = include_str!("builtin/create-skill/SKILL.md");
|
||
|
||
/// Returns the set of skills that are compiled into the Zed binary.
|
||
pub fn builtin_skills() -> Vec<Skill> {
|
||
let mut skills = Vec::new();
|
||
if let Ok(skill) = parse_builtin_skill("create-skill", CREATE_SKILL_CONTENT) {
|
||
skills.push(skill);
|
||
}
|
||
skills
|
||
}
|
||
|
||
/// Parse a built-in skill from its embedded SKILL.md content. The skill
|
||
/// gets a synthetic `<built-in>` path since it doesn't live on disk.
|
||
fn parse_builtin_skill(name: &str, content: &'static str) -> Result<Skill> {
|
||
let (metadata, body) = extract_frontmatter(content)?;
|
||
validate_name(&metadata.name)?;
|
||
validate_description(&metadata.description)?;
|
||
|
||
let synthetic_dir = PathBuf::from(format!("<built-in>/{}", name));
|
||
let synthetic_path = synthetic_dir.join(SKILL_FILE_NAME);
|
||
|
||
Ok(Skill {
|
||
name: metadata.name,
|
||
description: metadata.description,
|
||
source: SkillSource::BuiltIn,
|
||
directory_path: synthetic_dir,
|
||
skill_file_path: synthetic_path,
|
||
disable_model_invocation: metadata.disable_model_invocation,
|
||
embedded_body: Some(body.trim()),
|
||
})
|
||
}
|
||
|
||
/// All built-in skills as `(name, raw_content)` pairs. Used by
|
||
/// `builtin_skill_content` to serve the full SKILL.md without disk I/O.
|
||
const BUILTIN_SKILL_ENTRIES: &[(&str, &str)] = &[("create-skill", CREATE_SKILL_CONTENT)];
|
||
|
||
/// Look up the full embedded content of a built-in skill by its
|
||
/// synthetic file path. Returns `None` if the path doesn't match any
|
||
/// built-in skill.
|
||
pub fn builtin_skill_content(skill_file_path: &Path) -> Option<&'static str> {
|
||
BUILTIN_SKILL_ENTRIES.iter().find_map(|(name, content)| {
|
||
let expected = PathBuf::from(format!("<built-in>/{}", name)).join(SKILL_FILE_NAME);
|
||
(expected == skill_file_path).then_some(*content)
|
||
})
|
||
}
|
||
|
||
/// Returns the global skills directory: `~/.agents/skills`.
|
||
///
|
||
/// Other agents (e.g. Claude Code) already write skill files into this
|
||
/// location, so a Zed installation may have skills here even before the
|
||
/// rest of Zed's skills support ships.
|
||
///
|
||
/// In test builds, `paths::home_dir()` is hardcoded to a fixed path
|
||
/// (e.g. `/Users/zed`), so all tests using this function operate on the
|
||
/// same simulated home directory. Each test should use its own `FakeFs`
|
||
/// instance to keep skill setups from leaking across tests.
|
||
pub fn global_skills_dir() -> PathBuf {
|
||
paths::home_dir()
|
||
.join(AGENTS_DIR_NAME)
|
||
.join(SKILLS_DIR_NAME)
|
||
}
|
||
|
||
/// Project-local skills live at this path relative to a worktree root,
|
||
/// e.g. `<worktree>/.agents/skills/<skill>/SKILL.md`.
|
||
pub fn project_skills_relative_path() -> &'static str {
|
||
".agents/skills"
|
||
}
|
||
|
||
/// Returns `true` if `path` looks like it points into an agent skills
|
||
/// directory — i.e. it contains `AGENTS_DIR_NAME` immediately followed by
|
||
/// `SKILLS_DIR_NAME` as two consecutive path components, anywhere in the
|
||
/// path. Comparison is case-insensitive so it agrees with classifiers
|
||
/// that canonicalize against `~/.agents/skills` on case-insensitive
|
||
/// filesystems (macOS/Windows by default).
|
||
///
|
||
/// The path arriving here can be any of:
|
||
///
|
||
/// 1. Bare relative-to-worktree-root: `.agents/skills/...`
|
||
/// 2. Worktree-name prefixed: `<worktree>/.agents/skills/...`
|
||
/// 3. Absolute: `/path/to/worktree/.agents/skills/...`
|
||
///
|
||
/// Any-depth matching has a known cost: a `.agents/skills` directory
|
||
/// nested inside vendored sources (e.g. `vendor/x/.agents/skills/...`)
|
||
/// would also be flagged. We accept that as the safer-failing direction —
|
||
/// an extra confirmation prompt for a vendored file is annoying, while
|
||
/// silently letting the agent overwrite a `.agents/skills` tree the user
|
||
/// didn't expect to be touched is unsafe.
|
||
pub fn is_agents_skills_path(path: &Path) -> bool {
|
||
let mut components = path.components().map(|c| c.as_os_str());
|
||
let Some(mut prev) = components.next() else {
|
||
return false;
|
||
};
|
||
for curr in components {
|
||
if component_matches_ignore_ascii_case(prev, AGENTS_DIR_NAME)
|
||
&& component_matches_ignore_ascii_case(curr, SKILLS_DIR_NAME)
|
||
{
|
||
return true;
|
||
}
|
||
prev = curr;
|
||
}
|
||
false
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use fs::FakeFs;
|
||
use gpui::TestAppContext;
|
||
|
||
#[test]
|
||
fn test_skill_source_precedence_is_total_and_ordered() {
|
||
// Pin the hierarchy: project-local > global > built-in. Every
|
||
// override and conflict-resolution site routes through this,
|
||
// so the rest of the codebase relies on it being correct.
|
||
let built_in = SkillSource::BuiltIn.precedence();
|
||
let global = SkillSource::Global.precedence();
|
||
let project = SkillSource::ProjectLocal {
|
||
worktree_id: SkillScopeId(1),
|
||
worktree_root_name: "my-project".into(),
|
||
}
|
||
.precedence();
|
||
|
||
assert!(built_in < global, "global must shadow built-in");
|
||
assert!(global < project, "project-local must shadow global");
|
||
|
||
// Two project-local skills from different worktrees tie. The
|
||
// "first wins" convention is enforced by the callers, but the
|
||
// precedence itself must be equal so neither silently shadows
|
||
// the other.
|
||
let other_project = SkillSource::ProjectLocal {
|
||
worktree_id: SkillScopeId(2),
|
||
worktree_root_name: "other-project".into(),
|
||
}
|
||
.precedence();
|
||
assert_eq!(project, other_project);
|
||
}
|
||
|
||
#[test]
|
||
fn test_parse_valid_skill() {
|
||
let content = r#"---
|
||
name: my-skill
|
||
description: A test skill for testing purposes
|
||
---
|
||
|
||
# My Skill
|
||
|
||
## Instructions
|
||
Do the thing.
|
||
"#;
|
||
|
||
let result = parse_skill_frontmatter(
|
||
Path::new("/skills/my-skill/SKILL.md"),
|
||
content,
|
||
SkillSource::Global,
|
||
);
|
||
let skill = result.expect("Should parse successfully");
|
||
|
||
assert_eq!(skill.name, "my-skill");
|
||
assert_eq!(skill.description, "A test skill for testing purposes");
|
||
assert_eq!(skill.directory_path, Path::new("/skills/my-skill"));
|
||
// Default: skill is invocable by both model and user.
|
||
assert!(!skill.disable_model_invocation);
|
||
}
|
||
|
||
#[test]
|
||
fn test_parse_disable_model_invocation_true() {
|
||
let content = r#"---
|
||
name: deploy
|
||
description: Deploy the application to production.
|
||
disable-model-invocation: true
|
||
---
|
||
|
||
Steps to deploy.
|
||
"#;
|
||
|
||
let skill = parse_skill_frontmatter(
|
||
Path::new("/skills/deploy/SKILL.md"),
|
||
content,
|
||
SkillSource::Global,
|
||
)
|
||
.expect("should parse");
|
||
assert!(skill.disable_model_invocation);
|
||
}
|
||
|
||
#[test]
|
||
fn test_parse_disable_model_invocation_explicit_false() {
|
||
let content = r#"---
|
||
name: helper
|
||
description: A helper skill.
|
||
disable-model-invocation: false
|
||
---
|
||
|
||
Help.
|
||
"#;
|
||
|
||
let skill = parse_skill_frontmatter(
|
||
Path::new("/skills/helper/SKILL.md"),
|
||
content,
|
||
SkillSource::Global,
|
||
)
|
||
.expect("should parse");
|
||
assert!(!skill.disable_model_invocation);
|
||
}
|
||
|
||
#[test]
|
||
fn test_parse_missing_frontmatter() {
|
||
let content = "# My Skill\n\nNo frontmatter here.";
|
||
|
||
let result = parse_skill_frontmatter(
|
||
Path::new("/skills/test/SKILL.md"),
|
||
content,
|
||
SkillSource::Global,
|
||
);
|
||
assert!(result.is_err());
|
||
assert!(
|
||
result
|
||
.unwrap_err()
|
||
.to_string()
|
||
.contains("must start with YAML frontmatter")
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_parse_missing_closing_delimiter() {
|
||
let content = r#"---
|
||
name: test
|
||
description: Test
|
||
# No closing delimiter
|
||
"#;
|
||
|
||
let result = parse_skill_frontmatter(
|
||
Path::new("/skills/test/SKILL.md"),
|
||
content,
|
||
SkillSource::Global,
|
||
);
|
||
assert!(result.is_err());
|
||
assert!(
|
||
result
|
||
.unwrap_err()
|
||
.to_string()
|
||
.contains("missing closing frontmatter delimiter")
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_parse_empty_frontmatter_closing_on_next_line() {
|
||
// An empty frontmatter (closer immediately after the opener) is a real
|
||
// authoring case. Parsing should ultimately fail because the empty YAML
|
||
// doc lacks `name` and `description`, but the error must be the proper
|
||
// YAML/missing-field error rather than "missing closing frontmatter
|
||
// delimiter" — the closer is right there.
|
||
let content = "---\n---\nbody\n";
|
||
|
||
let result = parse_skill_frontmatter(
|
||
Path::new("/skills/test/SKILL.md"),
|
||
content,
|
||
SkillSource::Global,
|
||
);
|
||
assert!(result.is_err());
|
||
let err = result.unwrap_err();
|
||
let err_chain = format!("{:?}", err);
|
||
assert!(
|
||
!err_chain.contains("missing closing frontmatter delimiter"),
|
||
"Error should NOT be the missing-closer error since the closer is present: {}",
|
||
err_chain
|
||
);
|
||
assert!(
|
||
err_chain.contains("missing field")
|
||
|| err_chain.contains("name")
|
||
|| err_chain.contains("description")
|
||
|| err_chain.contains("Invalid YAML"),
|
||
"Error should mention missing name/description field or invalid YAML: {}",
|
||
err_chain
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_parse_missing_name() {
|
||
let content = r#"---
|
||
description: A test skill
|
||
---
|
||
|
||
Content here.
|
||
"#;
|
||
|
||
let result = parse_skill_frontmatter(
|
||
Path::new("/skills/test/SKILL.md"),
|
||
content,
|
||
SkillSource::Global,
|
||
);
|
||
assert!(result.is_err());
|
||
let err = result.unwrap_err();
|
||
let err_chain = format!("{:?}", err);
|
||
assert!(
|
||
err_chain.contains("missing field")
|
||
|| err_chain.contains("name")
|
||
|| err_chain.contains("Invalid YAML"),
|
||
"Error should mention missing name field or invalid YAML: {}",
|
||
err_chain
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_parse_missing_description() {
|
||
let content = r#"---
|
||
name: test-skill
|
||
---
|
||
|
||
Content here.
|
||
"#;
|
||
|
||
let result = parse_skill_frontmatter(
|
||
Path::new("/skills/test/SKILL.md"),
|
||
content,
|
||
SkillSource::Global,
|
||
);
|
||
assert!(result.is_err());
|
||
let err = result.unwrap_err();
|
||
let err_chain = format!("{:?}", err);
|
||
assert!(
|
||
err_chain.contains("missing field")
|
||
|| err_chain.contains("description")
|
||
|| err_chain.contains("Invalid YAML"),
|
||
"Error should mention missing description field or invalid YAML: {}",
|
||
err_chain
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_parse_name_too_long() {
|
||
let long_name = "a".repeat(65);
|
||
let content = format!(
|
||
r#"---
|
||
name: {long_name}
|
||
description: Test
|
||
---
|
||
|
||
Content.
|
||
"#
|
||
);
|
||
|
||
let result = parse_skill_frontmatter(
|
||
Path::new("/skills/test/SKILL.md"),
|
||
&content,
|
||
SkillSource::Global,
|
||
);
|
||
assert!(result.is_err());
|
||
assert!(
|
||
result
|
||
.unwrap_err()
|
||
.to_string()
|
||
.contains("at most 64 characters")
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_parse_name_invalid_chars() {
|
||
let content = r#"---
|
||
name: My_Skill
|
||
description: Test
|
||
---
|
||
|
||
Content.
|
||
"#;
|
||
|
||
let result = parse_skill_frontmatter(
|
||
Path::new("/skills/test/SKILL.md"),
|
||
content,
|
||
SkillSource::Global,
|
||
);
|
||
assert!(result.is_err());
|
||
assert!(
|
||
result
|
||
.unwrap_err()
|
||
.to_string()
|
||
.contains("lowercase letters, numbers, and hyphens")
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_slugify_basic() {
|
||
assert_eq!(
|
||
slugify_skill_name("My Cool Skill").as_deref(),
|
||
Some("my-cool-skill")
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_slugify_strips_invalid_chars() {
|
||
// Punctuation is dropped; spaces between words still produce dashes.
|
||
// `Hello,` → `hello`, then `␣` → `-`, then `World!` → `world`, etc.
|
||
assert_eq!(
|
||
slugify_skill_name("Hello, World! (v2)").as_deref(),
|
||
Some("hello-world-v2")
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_slugify_drops_punctuation_in_middle_no_spaces() {
|
||
// Punctuation between alphanumerics is dropped entirely — it does
|
||
// NOT become a dash. Only user-written spaces become dashes.
|
||
assert_eq!(slugify_skill_name("foo!bar").as_deref(), Some("foobar"));
|
||
assert_eq!(slugify_skill_name("foo?bar").as_deref(), Some("foobar"));
|
||
assert_eq!(slugify_skill_name("foo%bar").as_deref(), Some("foobar"));
|
||
assert_eq!(slugify_skill_name("100%sure").as_deref(), Some("100sure"));
|
||
assert_eq!(
|
||
slugify_skill_name("what's that").as_deref(),
|
||
Some("whats-that")
|
||
);
|
||
// `&` is special-cased to become `and` — see
|
||
// `test_slugify_ampersand_becomes_and` for the full coverage.
|
||
assert_eq!(
|
||
slugify_skill_name("don't&won't").as_deref(),
|
||
Some("dont-and-wont")
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_slugify_ampersand_becomes_and() {
|
||
// No spaces around `&`.
|
||
assert_eq!(
|
||
slugify_skill_name("foo&bar").as_deref(),
|
||
Some("foo-and-bar")
|
||
);
|
||
assert_eq!(
|
||
slugify_skill_name("rock&roll").as_deref(),
|
||
Some("rock-and-roll")
|
||
);
|
||
// Spaces around `&`: collapses to a single dash on each side.
|
||
assert_eq!(
|
||
slugify_skill_name("foo & bar").as_deref(),
|
||
Some("foo-and-bar")
|
||
);
|
||
// Asymmetric spacing.
|
||
assert_eq!(
|
||
slugify_skill_name("foo& bar").as_deref(),
|
||
Some("foo-and-bar")
|
||
);
|
||
assert_eq!(
|
||
slugify_skill_name("foo &bar").as_deref(),
|
||
Some("foo-and-bar")
|
||
);
|
||
// Leading/trailing `&`: the substituted spaces become leading/
|
||
// trailing dashes which then get trimmed.
|
||
assert_eq!(slugify_skill_name("&foo").as_deref(), Some("and-foo"));
|
||
assert_eq!(slugify_skill_name("foo&").as_deref(), Some("foo-and"));
|
||
// `&` alone slugifies to the word `and`, not to `None`.
|
||
assert_eq!(slugify_skill_name("&").as_deref(), Some("and"));
|
||
assert_eq!(slugify_skill_name(" & ").as_deref(), Some("and"));
|
||
// Multiple `&`s with various spacing all collapse properly.
|
||
assert_eq!(slugify_skill_name("&&").as_deref(), Some("and-and"));
|
||
assert_eq!(
|
||
slugify_skill_name("foo & & bar").as_deref(),
|
||
Some("foo-and-and-bar")
|
||
);
|
||
// Mixed with other punctuation (other punctuation is still dropped).
|
||
assert_eq!(slugify_skill_name("AT&T").as_deref(), Some("at-and-t"));
|
||
assert_eq!(slugify_skill_name("Q&A!").as_deref(), Some("q-and-a"));
|
||
}
|
||
|
||
#[test]
|
||
fn test_slugify_punctuation_surrounded_by_spaces() {
|
||
// `foo ! bar` → `foo-bar`: the two spaces would each produce a
|
||
// dash, but consecutive dashes are collapsed.
|
||
assert_eq!(slugify_skill_name("foo ! bar").as_deref(), Some("foo-bar"));
|
||
assert_eq!(slugify_skill_name("foo ? bar").as_deref(), Some("foo-bar"));
|
||
assert_eq!(
|
||
slugify_skill_name("100 % sure").as_deref(),
|
||
Some("100-sure")
|
||
);
|
||
assert_eq!(
|
||
slugify_skill_name("foo @ bar @ baz").as_deref(),
|
||
Some("foo-bar-baz")
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_slugify_punctuation_adjacent_to_space() {
|
||
// `foo! bar` and `foo !bar` both produce `foo-bar` — the
|
||
// punctuation contributes nothing, the single space contributes
|
||
// the dash.
|
||
assert_eq!(slugify_skill_name("foo! bar").as_deref(), Some("foo-bar"));
|
||
assert_eq!(slugify_skill_name("foo !bar").as_deref(), Some("foo-bar"));
|
||
assert_eq!(slugify_skill_name("foo? bar").as_deref(), Some("foo-bar"));
|
||
}
|
||
|
||
#[test]
|
||
fn test_slugify_leading_and_trailing_punctuation() {
|
||
// Punctuation at the edges is dropped; there's no leading/trailing
|
||
// dash to trim because the punctuation never became a dash in the
|
||
// first place.
|
||
assert_eq!(slugify_skill_name("!foo").as_deref(), Some("foo"));
|
||
assert_eq!(slugify_skill_name("foo!").as_deref(), Some("foo"));
|
||
assert_eq!(slugify_skill_name("!!!foo!!!").as_deref(), Some("foo"));
|
||
assert_eq!(slugify_skill_name("?foo?").as_deref(), Some("foo"));
|
||
assert_eq!(slugify_skill_name("...foo...").as_deref(), Some("foo"));
|
||
}
|
||
|
||
#[test]
|
||
fn test_slugify_only_punctuation_returns_none() {
|
||
assert_eq!(slugify_skill_name("!!!"), None);
|
||
assert_eq!(slugify_skill_name("?@$"), None);
|
||
assert_eq!(slugify_skill_name("()[]{}"), None);
|
||
assert_eq!(slugify_skill_name(".,;:"), None);
|
||
}
|
||
|
||
#[test]
|
||
fn test_slugify_mixed_punctuation_spaces_and_dashes() {
|
||
// A messy realistic input: combination of punctuation, spaces,
|
||
// existing dashes, and casing.
|
||
assert_eq!(
|
||
slugify_skill_name(" -- Hello, World!! -- ").as_deref(),
|
||
Some("hello-world")
|
||
);
|
||
assert_eq!(
|
||
slugify_skill_name("C++ vs. Rust?").as_deref(),
|
||
Some("c-vs-rust")
|
||
);
|
||
assert_eq!(
|
||
slugify_skill_name("v1.2.3-beta").as_deref(),
|
||
Some("v123-beta")
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_slugify_underscores_are_dropped() {
|
||
// Underscores aren't a valid skill-name character and aren't
|
||
// separators — only spaces become dashes — so underscores get
|
||
// dropped entirely.
|
||
assert_eq!(slugify_skill_name("foo_bar").as_deref(), Some("foobar"));
|
||
assert_eq!(slugify_skill_name("FOO_BAR").as_deref(), Some("foobar"));
|
||
assert_eq!(
|
||
slugify_skill_name("snake_case style").as_deref(),
|
||
Some("snakecase-style")
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_slugify_collapses_consecutive_dashes() {
|
||
assert_eq!(
|
||
slugify_skill_name("foo --- bar").as_deref(),
|
||
Some("foo-bar")
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_slugify_trims_leading_and_trailing_dashes() {
|
||
assert_eq!(slugify_skill_name("---foo---").as_deref(), Some("foo"));
|
||
assert_eq!(slugify_skill_name(" foo ").as_deref(), Some("foo"));
|
||
}
|
||
|
||
#[test]
|
||
fn test_slugify_lowercases() {
|
||
assert_eq!(slugify_skill_name("FOO BAR").as_deref(), Some("foo-bar"));
|
||
assert_eq!(
|
||
slugify_skill_name("MyCoolSkill").as_deref(),
|
||
Some("mycoolskill")
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_slugify_strips_non_ascii_letters() {
|
||
// Non-ASCII chars are replaced with `-`, then collapsed.
|
||
assert_eq!(slugify_skill_name("abc\u{00e9}").as_deref(), Some("abc"));
|
||
assert_eq!(slugify_skill_name("\u{4e2d}\u{6587}"), None);
|
||
}
|
||
|
||
#[test]
|
||
fn test_slugify_returns_none_for_empty_or_unmappable() {
|
||
assert_eq!(slugify_skill_name(""), None);
|
||
assert_eq!(slugify_skill_name(" "), None);
|
||
assert_eq!(slugify_skill_name("!!!"), None);
|
||
assert_eq!(slugify_skill_name("---"), None);
|
||
}
|
||
|
||
#[test]
|
||
fn test_slugify_truncates_long_inputs() {
|
||
let input = "a".repeat(200);
|
||
let slug = slugify_skill_name(&input).expect("should slugify");
|
||
assert_eq!(slug.len(), MAX_SKILL_NAME_LEN);
|
||
assert!(slug.chars().all(|c| c == 'a'));
|
||
}
|
||
|
||
#[test]
|
||
fn test_slugify_truncation_does_not_leave_trailing_dash() {
|
||
// The 64th byte lands on a `-`, which we must strip post-truncation.
|
||
let mut input = "a".repeat(63);
|
||
input.push_str(" extra");
|
||
let slug = slugify_skill_name(&input).expect("should slugify");
|
||
assert!(!slug.ends_with('-'));
|
||
assert!(slug.len() <= MAX_SKILL_NAME_LEN);
|
||
}
|
||
|
||
#[test]
|
||
fn test_slugify_output_passes_validate_name() {
|
||
for input in [
|
||
"My Cool Skill",
|
||
"Hello, World!",
|
||
"---foo---",
|
||
"123 abc",
|
||
"a".repeat(200).as_str(),
|
||
] {
|
||
let slug = slugify_skill_name(input).expect("should slugify");
|
||
validate_name(&slug).unwrap_or_else(|err| {
|
||
panic!("slug {slug:?} from {input:?} failed validation: {err}")
|
||
});
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn test_parse_description_too_long() {
|
||
let long_desc = "a".repeat(1025);
|
||
let content = format!(
|
||
r#"---
|
||
name: test
|
||
description: {long_desc}
|
||
---
|
||
|
||
Content.
|
||
"#
|
||
);
|
||
|
||
let result = parse_skill_frontmatter(
|
||
Path::new("/skills/test/SKILL.md"),
|
||
&content,
|
||
SkillSource::Global,
|
||
);
|
||
assert!(result.is_err());
|
||
assert!(
|
||
result
|
||
.unwrap_err()
|
||
.to_string()
|
||
.contains("at most 1024 characters")
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_parse_empty_description() {
|
||
let content = r#"---
|
||
name: test
|
||
description: ""
|
||
---
|
||
|
||
Content.
|
||
"#;
|
||
|
||
let result = parse_skill_frontmatter(
|
||
Path::new("/skills/test/SKILL.md"),
|
||
content,
|
||
SkillSource::Global,
|
||
);
|
||
assert!(result.is_err());
|
||
assert!(result.unwrap_err().to_string().contains("cannot be empty"));
|
||
}
|
||
|
||
#[test]
|
||
fn test_parse_file_too_large() {
|
||
let large_content = format!(
|
||
r#"---
|
||
name: test
|
||
description: Test skill
|
||
---
|
||
|
||
{}"#,
|
||
"x".repeat(MAX_SKILL_FILE_SIZE + 1)
|
||
);
|
||
|
||
let result = parse_skill_frontmatter(
|
||
Path::new("/skills/test/SKILL.md"),
|
||
&large_content,
|
||
SkillSource::Global,
|
||
);
|
||
assert!(result.is_err());
|
||
assert!(result.unwrap_err().to_string().contains("exceeds maximum"));
|
||
}
|
||
|
||
#[test]
|
||
fn test_parse_empty_body_after_frontmatter() {
|
||
let content = r#"---
|
||
name: minimal-skill
|
||
description: A skill with no body content
|
||
---
|
||
"#;
|
||
|
||
let result = parse_skill_frontmatter(
|
||
Path::new("/skills/minimal/SKILL.md"),
|
||
content,
|
||
SkillSource::Global,
|
||
);
|
||
|
||
let skill = result.expect("Empty body should be allowed");
|
||
assert_eq!(skill.name, "minimal-skill");
|
||
assert_eq!(skill.description, "A skill with no body content");
|
||
}
|
||
|
||
#[test]
|
||
fn test_parse_whitespace_only_body() {
|
||
let content = "---\nname: whitespace-skill\ndescription: Test\n---\n\n \n\n \n";
|
||
|
||
let result = parse_skill_frontmatter(
|
||
Path::new("/skills/ws/SKILL.md"),
|
||
content,
|
||
SkillSource::Global,
|
||
);
|
||
|
||
let skill = result.expect("Whitespace-only body should be allowed");
|
||
assert_eq!(skill.name, "whitespace-skill");
|
||
}
|
||
|
||
#[test]
|
||
fn test_parse_skill_with_crlf_line_endings() {
|
||
let content = "---\r\nname: crlf-skill\r\ndescription: A skill with CRLF line endings\r\n---\r\n\r\n# CRLF Skill\r\n\r\nDo the thing.\r\n";
|
||
|
||
let result = parse_skill_frontmatter(
|
||
Path::new("/skills/crlf-skill/SKILL.md"),
|
||
content,
|
||
SkillSource::Global,
|
||
);
|
||
let skill = result.expect("CRLF document should parse successfully");
|
||
|
||
assert_eq!(skill.name, "crlf-skill");
|
||
assert_eq!(skill.description, "A skill with CRLF line endings");
|
||
}
|
||
|
||
#[test]
|
||
fn test_parse_skill_with_mixed_line_endings() {
|
||
let content = "---\r\nname: mixed-skill\r\ndescription: Frontmatter uses CRLF, body uses LF\r\n---\r\n\n# Mixed Skill\n\nBody uses LF only.\n";
|
||
|
||
let result = parse_skill_frontmatter(
|
||
Path::new("/skills/mixed-skill/SKILL.md"),
|
||
content,
|
||
SkillSource::Global,
|
||
);
|
||
let skill = result.expect("Mixed line endings should parse successfully");
|
||
|
||
assert_eq!(skill.name, "mixed-skill");
|
||
assert_eq!(skill.description, "Frontmatter uses CRLF, body uses LF");
|
||
}
|
||
|
||
#[test]
|
||
fn test_parse_rejects_closing_delimiter_with_trailing_chars() {
|
||
// The only `---` after the opener has trailing junk on the same line,
|
||
// so it isn't a valid closing delimiter and parsing must error.
|
||
let content = "---\nname: foo\ndescription: bar\n---trailing-junk\nbody content\n";
|
||
|
||
let result = parse_skill_frontmatter(
|
||
Path::new("/skills/test/SKILL.md"),
|
||
content,
|
||
SkillSource::Global,
|
||
);
|
||
assert!(result.is_err());
|
||
assert!(
|
||
result
|
||
.unwrap_err()
|
||
.to_string()
|
||
.contains("missing closing frontmatter delimiter")
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_parse_accepts_only_truly_terminated_closing_delimiter() {
|
||
// The first `---trailing` appears inside a quoted YAML string and is
|
||
// NOT alone on its line, so it must not be treated as the closer.
|
||
// The real closer comes later as `\n---\n`.
|
||
let content = "---\nname: skill-name\ndescription: A real description\nsummary: \"---trailing\"\n---\nbody content\n";
|
||
|
||
let skill = parse_skill_frontmatter(
|
||
Path::new("/skills/skill-name/SKILL.md"),
|
||
content,
|
||
SkillSource::Global,
|
||
)
|
||
.expect("Should pick the truly-terminated closing delimiter");
|
||
|
||
assert_eq!(skill.name, "skill-name");
|
||
assert_eq!(skill.description, "A real description");
|
||
}
|
||
|
||
#[test]
|
||
fn test_parse_accepts_four_dashes_as_invalid_closer() {
|
||
// A line of four dashes is NOT a valid closing delimiter; with no
|
||
// valid closer following, parsing must error.
|
||
let content = "---\nname: foo\ndescription: bar\n----\nbody content\n";
|
||
|
||
let result = parse_skill_frontmatter(
|
||
Path::new("/skills/test/SKILL.md"),
|
||
content,
|
||
SkillSource::Global,
|
||
);
|
||
assert!(result.is_err());
|
||
assert!(
|
||
result
|
||
.unwrap_err()
|
||
.to_string()
|
||
.contains("missing closing frontmatter delimiter")
|
||
);
|
||
}
|
||
|
||
#[gpui::test]
|
||
async fn test_load_skills_from_empty_directory(cx: &mut TestAppContext) {
|
||
let fs = FakeFs::new(cx.executor());
|
||
fs.insert_tree("/skills", serde_json::json!({})).await;
|
||
|
||
let results = load_skills_from_directory(
|
||
&(fs as Arc<dyn Fs>),
|
||
Path::new("/skills"),
|
||
SkillSource::Global,
|
||
)
|
||
.await;
|
||
assert!(results.is_empty());
|
||
}
|
||
|
||
#[gpui::test]
|
||
async fn test_load_single_skill(cx: &mut TestAppContext) {
|
||
let fs = FakeFs::new(cx.executor());
|
||
fs.insert_tree(
|
||
"/skills",
|
||
serde_json::json!({
|
||
"my-skill": {
|
||
"SKILL.md": "---\nname: my-skill\ndescription: Test skill\n---\n\n# Instructions\nDo stuff."
|
||
}
|
||
}),
|
||
)
|
||
.await;
|
||
|
||
let results = load_skills_from_directory(
|
||
&(fs as Arc<dyn Fs>),
|
||
Path::new("/skills"),
|
||
SkillSource::Global,
|
||
)
|
||
.await;
|
||
|
||
assert_eq!(results.len(), 1);
|
||
let skill = results[0].as_ref().expect("Should load successfully");
|
||
assert_eq!(skill.name, "my-skill");
|
||
assert_eq!(skill.description, "Test skill");
|
||
}
|
||
|
||
#[gpui::test]
|
||
async fn test_load_nested_skills(cx: &mut TestAppContext) {
|
||
let fs = FakeFs::new(cx.executor());
|
||
fs.insert_tree(
|
||
"/skills",
|
||
serde_json::json!({
|
||
"skill-one": {
|
||
"SKILL.md": "---\nname: skill-one\ndescription: First skill\n---\n\nContent one"
|
||
},
|
||
"skill-two": {
|
||
"SKILL.md": "---\nname: skill-two\ndescription: Second skill\n---\n\nContent two"
|
||
}
|
||
}),
|
||
)
|
||
.await;
|
||
|
||
let results = load_skills_from_directory(
|
||
&(fs as Arc<dyn Fs>),
|
||
Path::new("/skills"),
|
||
SkillSource::Global,
|
||
)
|
||
.await;
|
||
|
||
assert_eq!(results.len(), 2);
|
||
let names: Vec<&str> = results
|
||
.iter()
|
||
.filter_map(|r| r.as_ref().ok())
|
||
.map(|s| s.name.as_str())
|
||
.collect();
|
||
assert!(names.contains(&"skill-one"));
|
||
assert!(names.contains(&"skill-two"));
|
||
}
|
||
|
||
#[gpui::test]
|
||
async fn test_load_skills_returns_results_sorted_by_path(cx: &mut TestAppContext) {
|
||
// `apply_skill_overrides` resolves same-source name collisions
|
||
// by keeping the first entry in iteration order. Without a
|
||
// stable sort here, the result depends on `fs.read_dir`, which
|
||
// is OS/filesystem-dependent. Assert the contract: results
|
||
// come back sorted by skill file path regardless of insertion
|
||
// order.
|
||
let fs = FakeFs::new(cx.executor());
|
||
fs.insert_tree(
|
||
"/skills",
|
||
serde_json::json!({
|
||
"charlie": {
|
||
"SKILL.md": "---\nname: charlie\ndescription: C\n---\n\nC"
|
||
},
|
||
"alpha": {
|
||
"SKILL.md": "---\nname: alpha\ndescription: A\n---\n\nA"
|
||
},
|
||
"bravo": {
|
||
"SKILL.md": "---\nname: bravo\ndescription: B\n---\n\nB"
|
||
},
|
||
"delta": {
|
||
"SKILL.md": "No frontmatter, will fail"
|
||
},
|
||
}),
|
||
)
|
||
.await;
|
||
|
||
let results = load_skills_from_directory(
|
||
&(fs as Arc<dyn Fs>),
|
||
Path::new("/skills"),
|
||
SkillSource::Global,
|
||
)
|
||
.await;
|
||
|
||
assert_eq!(results.len(), 4);
|
||
|
||
let paths: Vec<PathBuf> = results
|
||
.iter()
|
||
.map(|r| match r {
|
||
Ok(skill) => skill.skill_file_path.clone(),
|
||
Err(error) => error.path.clone(),
|
||
})
|
||
.collect();
|
||
|
||
let mut expected = paths.clone();
|
||
expected.sort();
|
||
assert_eq!(paths, expected);
|
||
}
|
||
|
||
#[gpui::test]
|
||
async fn test_load_ignores_non_skill_files(cx: &mut TestAppContext) {
|
||
let fs = FakeFs::new(cx.executor());
|
||
fs.insert_tree(
|
||
"/skills",
|
||
serde_json::json!({
|
||
"my-skill": {
|
||
"SKILL.md": "---\nname: my-skill\ndescription: Test\n---\n\nContent"
|
||
},
|
||
"not-a-skill.txt": "This is not a skill",
|
||
"some-dir": {
|
||
"other-file.md": "Not a SKILL.md"
|
||
}
|
||
}),
|
||
)
|
||
.await;
|
||
|
||
let results = load_skills_from_directory(
|
||
&(fs as Arc<dyn Fs>),
|
||
Path::new("/skills"),
|
||
SkillSource::Global,
|
||
)
|
||
.await;
|
||
|
||
assert_eq!(results.len(), 1);
|
||
let skill = results[0].as_ref().expect("Should load successfully");
|
||
assert_eq!(skill.name, "my-skill");
|
||
}
|
||
|
||
#[gpui::test]
|
||
async fn test_load_returns_errors_for_invalid_skills(cx: &mut TestAppContext) {
|
||
let fs = FakeFs::new(cx.executor());
|
||
fs.insert_tree(
|
||
"/skills",
|
||
serde_json::json!({
|
||
"valid-skill": {
|
||
"SKILL.md": "---\nname: valid-skill\ndescription: Valid\n---\n\nContent"
|
||
},
|
||
"invalid-skill": {
|
||
"SKILL.md": "No frontmatter here"
|
||
}
|
||
}),
|
||
)
|
||
.await;
|
||
|
||
let results = load_skills_from_directory(
|
||
&(fs as Arc<dyn Fs>),
|
||
Path::new("/skills"),
|
||
SkillSource::Global,
|
||
)
|
||
.await;
|
||
|
||
assert_eq!(results.len(), 2);
|
||
|
||
let (successes, errors): (Vec<_>, Vec<_>) = results.iter().partition(|r| r.is_ok());
|
||
|
||
assert_eq!(successes.len(), 1);
|
||
assert_eq!(errors.len(), 1);
|
||
|
||
let error = errors[0].as_ref().unwrap_err();
|
||
assert!(error.path.to_string_lossy().contains("invalid-skill"));
|
||
}
|
||
|
||
#[gpui::test]
|
||
async fn test_load_from_nonexistent_directory(cx: &mut TestAppContext) {
|
||
let fs = FakeFs::new(cx.executor());
|
||
|
||
let results = load_skills_from_directory(
|
||
&(fs as Arc<dyn Fs>),
|
||
Path::new("/nonexistent"),
|
||
SkillSource::Global,
|
||
)
|
||
.await;
|
||
|
||
assert!(results.is_empty());
|
||
}
|
||
|
||
#[test]
|
||
fn test_skill_summary_from_skill() {
|
||
let skill = Skill {
|
||
name: "test-skill".to_string(),
|
||
description: "A test description".to_string(),
|
||
source: SkillSource::Global,
|
||
directory_path: PathBuf::from("/skills/test-skill"),
|
||
skill_file_path: PathBuf::from("/skills/test-skill/SKILL.md"),
|
||
disable_model_invocation: false,
|
||
embedded_body: None,
|
||
};
|
||
|
||
let summary = SkillSummary::from(&skill);
|
||
assert_eq!(summary.name, "test-skill");
|
||
assert_eq!(summary.description, "A test description");
|
||
assert_eq!(summary.location, "/skills/test-skill/SKILL.md");
|
||
}
|
||
|
||
#[gpui::test]
|
||
async fn test_nested_skill_md_inside_skill_resources_is_not_loaded(cx: &mut TestAppContext) {
|
||
// We only look at immediate children of the skills root, so a
|
||
// `SKILL.md` nested inside a skill's resources directory cannot
|
||
// accidentally be picked up as a separate skill.
|
||
let fs = FakeFs::new(cx.executor());
|
||
fs.insert_tree(
|
||
"/skills",
|
||
serde_json::json!({
|
||
"outer": {
|
||
"SKILL.md": "---\nname: outer\ndescription: Outer skill\n---\n\nBody",
|
||
"references": {
|
||
"SKILL.md": "---\nname: bogus-inner\ndescription: Should not load\n---\n\nBody"
|
||
},
|
||
},
|
||
}),
|
||
)
|
||
.await;
|
||
|
||
let results = load_skills_from_directory(
|
||
&(fs as Arc<dyn Fs>),
|
||
Path::new("/skills"),
|
||
SkillSource::Global,
|
||
)
|
||
.await;
|
||
|
||
let names: Vec<&str> = results
|
||
.iter()
|
||
.filter_map(|r| r.as_ref().ok())
|
||
.map(|s| s.name.as_str())
|
||
.collect();
|
||
assert_eq!(names, vec!["outer"]);
|
||
}
|
||
|
||
#[gpui::test]
|
||
async fn test_load_oversized_skill_file_short_circuits(cx: &mut TestAppContext) {
|
||
// A `SKILL.md` whose size exceeds `MAX_SKILL_FILE_SIZE` must be
|
||
// rejected via metadata before we read its contents into memory.
|
||
// Otherwise a stray multi-GB file dropped into a skill directory
|
||
// would OOM the application before `parse_skill`'s size check fires.
|
||
let fs = FakeFs::new(cx.executor());
|
||
let oversized_body = "x".repeat(MAX_SKILL_FILE_SIZE + 1);
|
||
let oversized_content = format!(
|
||
"---\nname: huge\ndescription: Too big\n---\n\n{}",
|
||
oversized_body
|
||
);
|
||
fs.insert_tree(
|
||
"/skills",
|
||
serde_json::json!({
|
||
"huge": {
|
||
"SKILL.md": oversized_content,
|
||
}
|
||
}),
|
||
)
|
||
.await;
|
||
|
||
let results = load_skills_from_directory(
|
||
&(fs as Arc<dyn Fs>),
|
||
Path::new("/skills"),
|
||
SkillSource::Global,
|
||
)
|
||
.await;
|
||
|
||
assert_eq!(results.len(), 1);
|
||
let err = results[0].as_ref().expect_err("Oversized file must error");
|
||
assert!(
|
||
err.message.contains("exceeds maximum size"),
|
||
"unexpected error message: {}",
|
||
err.message
|
||
);
|
||
}
|
||
|
||
#[gpui::test]
|
||
async fn test_load_skill_frontmatter_parses_metadata_without_body(cx: &mut TestAppContext) {
|
||
// `load_skill_frontmatter` should read just enough of the file to
|
||
// parse the frontmatter and return a `Skill` with name/description/
|
||
// disable_model_invocation populated. The body is intentionally not
|
||
// surfaced; callers go through `read_skill_body` for that.
|
||
let fs = FakeFs::new(cx.executor());
|
||
fs.insert_tree(
|
||
"/skills",
|
||
serde_json::json!({
|
||
"my-skill": {
|
||
"SKILL.md": "---\nname: my-skill\ndescription: A skill for tests\ndisable-model-invocation: true\n---\n\n# Body\n\nLots of body text here.\n"
|
||
}
|
||
}),
|
||
)
|
||
.await;
|
||
|
||
let skill = load_skill_frontmatter(
|
||
fs as Arc<dyn Fs>,
|
||
PathBuf::from("/skills/my-skill/SKILL.md"),
|
||
SkillSource::Global,
|
||
)
|
||
.await
|
||
.expect("frontmatter should parse");
|
||
|
||
assert_eq!(skill.name, "my-skill");
|
||
assert_eq!(skill.description, "A skill for tests");
|
||
assert!(skill.disable_model_invocation);
|
||
assert_eq!(
|
||
skill.skill_file_path,
|
||
PathBuf::from("/skills/my-skill/SKILL.md")
|
||
);
|
||
assert_eq!(skill.directory_path, PathBuf::from("/skills/my-skill"));
|
||
}
|
||
|
||
#[gpui::test]
|
||
async fn test_read_skill_body_returns_trimmed_body(cx: &mut TestAppContext) {
|
||
let fs = FakeFs::new(cx.executor());
|
||
fs.insert_tree(
|
||
"/skills",
|
||
serde_json::json!({
|
||
"my-skill": {
|
||
"SKILL.md": "---\nname: my-skill\ndescription: Test skill\n---\n\n# Instructions\n\nDo the thing.\n\n"
|
||
}
|
||
}),
|
||
)
|
||
.await;
|
||
|
||
let body = read_skill_body(fs.as_ref(), Path::new("/skills/my-skill/SKILL.md"))
|
||
.await
|
||
.expect("body should load");
|
||
|
||
// Trimmed: no leading blank line after the closing `---`, and no
|
||
// trailing whitespace.
|
||
assert_eq!(body, "# Instructions\n\nDo the thing.");
|
||
}
|
||
|
||
#[gpui::test]
|
||
async fn test_read_skill_body_for_skill_without_body(cx: &mut TestAppContext) {
|
||
let fs = FakeFs::new(cx.executor());
|
||
fs.insert_tree(
|
||
"/skills",
|
||
serde_json::json!({
|
||
"empty": {
|
||
"SKILL.md": "---\nname: empty\ndescription: No body\n---\n"
|
||
}
|
||
}),
|
||
)
|
||
.await;
|
||
|
||
let body = read_skill_body(fs.as_ref(), Path::new("/skills/empty/SKILL.md"))
|
||
.await
|
||
.expect("body should load");
|
||
|
||
assert!(body.is_empty(), "expected empty body, got: {body:?}");
|
||
}
|
||
|
||
#[test]
|
||
fn is_agents_skills_path_simple_positive() {
|
||
assert!(is_agents_skills_path(Path::new(
|
||
"foo/.agents/skills/my-skill/SKILL.md"
|
||
)));
|
||
}
|
||
|
||
#[test]
|
||
fn is_agents_skills_path_simple_negative() {
|
||
assert!(!is_agents_skills_path(Path::new("foo/bar/baz")));
|
||
}
|
||
|
||
#[test]
|
||
fn is_agents_skills_path_double_agents() {
|
||
// `foo/.agents/.agents/skills` contains a `.agents/skills` pair at
|
||
// depths 2-3. Any-depth matching catches it; this is intentional, so
|
||
// a `.agents/skills` directory the user wasn't expecting to be
|
||
// touched still prompts for confirmation.
|
||
assert!(is_agents_skills_path(Path::new(
|
||
"foo/.agents/.agents/skills"
|
||
)));
|
||
}
|
||
|
||
#[test]
|
||
fn is_agents_skills_path_agents_without_skills() {
|
||
assert!(!is_agents_skills_path(Path::new("foo/.agents/other")));
|
||
}
|
||
|
||
#[test]
|
||
fn is_agents_skills_path_at_start() {
|
||
assert!(is_agents_skills_path(Path::new(".agents/skills")));
|
||
}
|
||
|
||
#[test]
|
||
fn is_agents_skills_path_trailing_agents() {
|
||
assert!(!is_agents_skills_path(Path::new("foo/.agents")));
|
||
}
|
||
|
||
#[test]
|
||
fn is_agents_skills_path_deep_match() {
|
||
// Any-depth matching: nested `.agents/skills` directories — e.g.
|
||
// inside vendored sources — are flagged too. We prefer the extra
|
||
// prompt over silently letting the agent edit something named
|
||
// `.agents/skills`.
|
||
assert!(is_agents_skills_path(Path::new("a/b/.agents/skills/x.txt")));
|
||
assert!(is_agents_skills_path(Path::new(
|
||
"some/random/place/.agents/skills/foo"
|
||
)));
|
||
}
|
||
|
||
#[test]
|
||
fn is_agents_skills_path_absolute() {
|
||
// Absolute paths into a project-local `.agents/skills/` are caught
|
||
// by the same consecutive-component match.
|
||
assert!(is_agents_skills_path(Path::new(
|
||
"/Users/foo/project/.agents/skills/my-skill/SKILL.md"
|
||
)));
|
||
assert!(!is_agents_skills_path(Path::new("/etc/hosts")));
|
||
}
|
||
|
||
#[test]
|
||
fn is_agents_skills_path_case_insensitive() {
|
||
// Filesystems on macOS/Windows are case-insensitive by default; the
|
||
// classifier must agree.
|
||
assert!(is_agents_skills_path(Path::new(".AGENTS/skills/foo")));
|
||
assert!(is_agents_skills_path(Path::new(".agents/SKILLS/foo")));
|
||
assert!(is_agents_skills_path(Path::new(
|
||
"project/.AGENTS/SKILLS/foo"
|
||
)));
|
||
}
|
||
}
|