Skip to repository content

tenant.openagents/omega

No repository description is available.

OpenAgents Git authority 2026-07-28T00:54:37.927Z Public web read
NIP-34 coordinate30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omega
MaintainersHidden in public view
References2 branches · 1 tag
Read-only clonegit clone https://openagents.com/git/tenant.openagents/omega.git
Browse files

agent_skills.rs

2188 lines · 76.3 KB · rust
1use anyhow::{Context as _, Result};
2use const_format::{concatcp, formatcp};
3use fs::Fs;
4use futures::StreamExt;
5use gpui::{App, Global, SharedString};
6use serde::{Deserialize, Serialize};
7use std::path::{Path, PathBuf};
8use std::rc::Rc;
9use std::sync::Arc;
10use url::Url;
11use util::paths::component_matches_ignore_ascii_case;
12
13/// First segment of the skills directory path: `.agents`.
14pub const AGENTS_DIR_NAME: &str = ".agents";
15
16/// Second segment of the skills directory path: `skills`.
17pub const SKILLS_DIR_NAME: &str = "skills";
18
19/// User-facing display form of the global skills directory path — i.e.
20/// what a human should see in messages and prompts, with the platform's
21/// native path separator and home-directory shorthand.
22///
23/// Windows doesn't recognize `~` as the home directory, so the env-var
24/// form is used there instead.
25#[cfg(target_os = "windows")]
26pub const GLOBAL_SKILLS_DIR_DISPLAY: &str =
27    concatcp!("%USERPROFILE%\\", AGENTS_DIR_NAME, "\\", SKILLS_DIR_NAME);
28#[cfg(not(target_os = "windows"))]
29pub const GLOBAL_SKILLS_DIR_DISPLAY: &str = concatcp!("~/", AGENTS_DIR_NAME, "/", SKILLS_DIR_NAME);
30
31/// Opaque identifier for the project scope a skill was loaded from.
32///
33/// `agent_skills` is a leaf crate and intentionally does not depend on
34/// `worktree`. Callers (e.g. the `agent` crate) construct these from
35/// `worktree::WorktreeId::to_usize()` and recover the original ID via
36/// `worktree::WorktreeId::from_usize()` when needed.
37#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
38pub struct SkillScopeId(pub usize);
39
40/// Cap on concurrent filesystem operations during skill discovery and loading.
41/// Without this bound, a `.agents/skills` directory containing thousands of
42/// entries would fan out an equally large number of concurrent OS-level I/O
43/// operations, potentially exhausting file descriptors or stalling the app.
44const SKILL_IO_CONCURRENCY: usize = 16;
45
46/// Maximum size for a single SKILL.md file (100KB)
47pub const MAX_SKILL_FILE_SIZE: usize = 100 * 1024;
48
49/// Maximum total size for skill descriptions in system prompt (50KB)
50pub const MAX_SKILL_DESCRIPTIONS_SIZE: usize = 50 * 1024;
51
52/// The name of the skill definition file
53pub const SKILL_FILE_NAME: &str = "SKILL.md";
54
55#[derive(Debug, Clone, PartialEq, Eq, Hash)]
56pub enum SkillLoadWarning {
57    DescriptionTooLong { actual_len: usize, max_len: usize },
58}
59
60impl SkillLoadWarning {
61    pub fn message(&self) -> String {
62        match self {
63            Self::DescriptionTooLong {
64                actual_len,
65                max_len,
66            } => format!(
67                "Skill description is {actual_len} bytes, exceeding the {max_len}-byte limit. The skill was loaded, but long descriptions may consume more model-context tokens."
68            ),
69        }
70    }
71}
72
73/// Represents a loaded skill with all its metadata and content.
74#[derive(Debug, Clone)]
75pub struct Skill {
76    pub name: String,
77    pub description: String,
78    pub source: SkillSource,
79    /// Absolute path to the skill directory
80    pub directory_path: PathBuf,
81    /// Absolute path to the SKILL.md file
82    pub skill_file_path: PathBuf,
83    /// Non-fatal issues found while loading this skill.
84    pub load_warnings: Vec<SkillLoadWarning>,
85    /// When `true`, this skill is hidden from the model's catalog and the
86    /// `skill` tool refuses to load it. The user can still invoke it as a
87    /// slash command.
88    pub disable_model_invocation: bool,
89    /// For built-in skills whose content is compiled into the binary,
90    /// this holds the full SKILL.md body so the skill tool can serve it
91    /// without a filesystem read.
92    pub embedded_body: Option<&'static str>,
93}
94
95/// Indicates where a skill was loaded from.
96#[derive(Debug, Clone, PartialEq, Eq)]
97pub enum SkillSource {
98    /// Compiled into the Zed binary. These are always available and have
99    /// the lowest override priority (global and project-local skills can
100    /// shadow them).
101    BuiltIn,
102    /// From ~/.agents/skills/
103    Global,
104    /// From {project}/.agents/skills/
105    ProjectLocal {
106        worktree_id: SkillScopeId,
107        worktree_root_name: Arc<str>,
108    },
109}
110
111impl SkillSource {
112    /// Precedence for resolving same-named skills. Higher values shadow
113    /// lower ones: `ProjectLocal` > `Global` > `BuiltIn`. Two sources
114    /// returning equal precedence (e.g. two project-local skills from
115    /// different worktrees) leave the winner up to the caller, which by
116    /// convention keeps the first one in iteration order.
117    ///
118    /// Adding a new `SkillSource` variant should be a one-line change
119    /// here — every consumer routes through this method so the hierarchy
120    /// stays in sync.
121    pub fn precedence(&self) -> u8 {
122        match self {
123            Self::BuiltIn => 0,
124            Self::Global => 1,
125            Self::ProjectLocal { .. } => 2,
126        }
127    }
128
129    /// Scope prefix used in the `/<prefix>:<name>` slash-command
130    /// syntax that the autocomplete popup inserts. Global skills use
131    /// an empty prefix (so the inserted text is `/:<name>`), and
132    /// project-local skills use their worktree root name (so the
133    /// inserted text is `/<worktree>:<name>`).
134    ///
135    /// Using an empty prefix for globals rather than a literal
136    /// `global` means a worktree literally named `global` is no
137    /// longer ambiguous with the global source: the global skill is
138    /// invoked as `/:<name>`, and the worktree's skill is invoked as
139    /// `/global:<name>`. The two grammars never collide on the
140    /// inserted text.
141    /// Human-readable label for this source, used in the UI to
142    /// distinguish skills from different origins.
143    pub fn display_label(&self) -> &str {
144        match self {
145            Self::BuiltIn => "built-in",
146            Self::Global => "global",
147            Self::ProjectLocal {
148                worktree_root_name, ..
149            } => worktree_root_name.as_ref(),
150        }
151    }
152
153    pub fn scope_prefix(&self) -> &str {
154        match self {
155            Self::BuiltIn | Self::Global => "",
156            Self::ProjectLocal {
157                worktree_root_name, ..
158            } => worktree_root_name.as_ref(),
159        }
160    }
161
162    /// Whether this source matches the given scope qualifier from a
163    /// `/<scope>:<name>` slash command. The empty scope is reserved
164    /// for global skills; non-empty scopes match a project-local
165    /// skill whose worktree root name equals the scope.
166    ///
167    /// Hand-typed `/global:<name>` is NOT treated as an alias for
168    /// `/:<name>`. It looks for a project-local skill from a worktree
169    /// named `global` and fails if none exists. The popup always
170    /// inserts the unambiguous form (`/:<name>` for globals), so this
171    /// strictness only affects users typing by memory.
172    pub fn matches_scope(&self, scope: &str) -> bool {
173        match self {
174            Self::BuiltIn | Self::Global => scope.is_empty(),
175            Self::ProjectLocal {
176                worktree_root_name, ..
177            } => !scope.is_empty() && worktree_root_name.as_ref() == scope,
178        }
179    }
180}
181
182/// App-wide index of loaded skills, published by NativeAgent and read
183/// by any UI that needs to display the skill list (e.g. Settings UI).
184#[derive(Default)]
185pub struct SkillIndex {
186    pub global_skills: Vec<Skill>,
187    pub project_skills: Vec<ProjectSkillGroup>,
188}
189
190#[derive(Clone)]
191pub struct ProjectSkillGroup {
192    pub worktree_id: SkillScopeId,
193    pub worktree_root_name: SharedString,
194    pub skills: Vec<Skill>,
195}
196
197impl Global for SkillIndex {}
198
199/// Rescan skill agent skill directories when skills are created or modified via UI
200pub struct SkillsUpdatedHook(pub Rc<dyn Fn(&mut App)>);
201
202impl Global for SkillsUpdatedHook {}
203
204/// Just the frontmatter, used for parsing
205#[derive(Debug, Clone, Serialize, Deserialize)]
206pub struct SkillMetadata {
207    pub name: String,
208    pub description: String,
209    #[serde(default, rename = "disable-model-invocation")]
210    pub disable_model_invocation: bool,
211}
212
213/// Minimal skill info for system prompt.
214///
215/// `Serialize` is required for handlebars rendering of the system prompt
216/// template (see `ProjectContext` in `prompt_store`). `PartialEq, Eq` lets
217/// the agent compare freshly-built `ProjectContext`s and skip pushing an
218/// unchanged value through the project_context entity (which would
219/// otherwise look like a system-prompt change to the model and invalidate
220/// the API's prompt cache).
221#[derive(Debug, Clone, Eq, PartialEq, Serialize)]
222pub struct SkillSummary {
223    pub name: String,
224    pub description: String,
225    /// Absolute path to the SKILL.md file, so the model can resolve
226    /// references relative to the skill's directory when reading bundled
227    /// resources.
228    pub location: String,
229}
230
231impl From<&Skill> for SkillSummary {
232    fn from(skill: &Skill) -> Self {
233        Self {
234            name: skill.name.clone(),
235            description: skill.description.clone(),
236            location: skill.skill_file_path.to_string_lossy().into_owned(),
237        }
238    }
239}
240
241/// Error that occurred while loading a skill
242#[derive(Debug, Clone)]
243pub struct SkillLoadError {
244    pub path: PathBuf,
245    pub message: String,
246}
247
248impl std::fmt::Display for SkillLoadError {
249    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
250        write!(f, "{}: {}", self.path.display(), self.message)
251    }
252}
253
254impl std::error::Error for SkillLoadError {}
255
256/// Parse the frontmatter of a SKILL.md file into a `Skill` struct.
257///
258/// The file must have YAML frontmatter between `---` delimiters containing
259/// `name` and `description` fields. The body (everything after the closing
260/// `---`) is intentionally NOT returned — it's read on demand via
261/// `read_skill_body` when the skill is actually being materialized for the
262/// model, so we don't pay N × body-size in memory for N skills.
263///
264/// `content` only needs to contain bytes up through the closing `---`; any
265/// trailing body bytes are ignored.
266pub fn parse_skill_frontmatter(
267    skill_file_path: &Path,
268    content: &str,
269    source: SkillSource,
270) -> Result<Skill> {
271    let (metadata, _body, load_warnings) = parse_skill_file_content_for_loading(content)?;
272
273    let directory_path = skill_file_path
274        .parent()
275        .context("SKILL.md file has no parent directory")?
276        .to_path_buf();
277
278    Ok(Skill {
279        name: metadata.name,
280        description: metadata.description,
281        source,
282        directory_path,
283        skill_file_path: skill_file_path.to_path_buf(),
284        load_warnings,
285        disable_model_invocation: metadata.disable_model_invocation,
286        embedded_body: None,
287    })
288}
289
290/// Extract the YAML frontmatter and body from a SKILL.md file without
291/// validating the metadata fields.
292pub fn extract_skill_frontmatter(content: &str) -> Result<(SkillMetadata, &str)> {
293    if content.len() > MAX_SKILL_FILE_SIZE {
294        anyhow::bail!(
295            "SKILL.md file exceeds maximum size of {}KB",
296            MAX_SKILL_FILE_SIZE / 1024
297        );
298    }
299
300    extract_frontmatter(content)
301}
302
303/// Parse and validate the YAML frontmatter and body from a SKILL.md file.
304pub fn parse_skill_file_content(content: &str) -> Result<(SkillMetadata, &str)> {
305    let (metadata, body) = extract_skill_frontmatter(content)?;
306
307    validate_name(&metadata.name).map_err(anyhow::Error::msg)?;
308    validate_description(&metadata.description).map_err(anyhow::Error::msg)?;
309
310    Ok((metadata, body))
311}
312
313fn parse_skill_file_content_for_loading(
314    content: &str,
315) -> Result<(SkillMetadata, &str, Vec<SkillLoadWarning>)> {
316    let (metadata, body) = extract_skill_frontmatter(content)?;
317
318    validate_name(&metadata.name).map_err(anyhow::Error::msg)?;
319    let load_warnings =
320        validate_description_for_loading(&metadata.description).map_err(anyhow::Error::msg)?;
321
322    Ok((metadata, body, load_warnings))
323}
324
325fn validate_description_for_loading(
326    description: &str,
327) -> Result<Vec<SkillLoadWarning>, &'static str> {
328    if description.trim().is_empty() {
329        return Err("Skill description cannot be empty");
330    }
331
332    let mut warnings = Vec::new();
333    if description.len() > MAX_SKILL_DESCRIPTION_LEN {
334        warnings.push(SkillLoadWarning::DescriptionTooLong {
335            actual_len: description.len(),
336            max_len: MAX_SKILL_DESCRIPTION_LEN,
337        });
338    }
339
340    Ok(warnings)
341}
342
343fn extract_frontmatter(content: &str) -> Result<(SkillMetadata, &str)> {
344    let content = content.trim_start();
345
346    if !content.starts_with("---") {
347        anyhow::bail!("SKILL.md must start with YAML frontmatter (---)");
348    }
349
350    // Find every candidate closing `---` line: a line consisting EXACTLY of
351    // `---` (followed by `\n`, `\r\n`, or EOF) at column 0, excluding the
352    // opening line itself. The opener occupies bytes 0..(first line ending),
353    // and our scan starts after each `\n`, so the opener is naturally skipped.
354    //
355    // For each candidate we record the byte position right after its line
356    // ending; that's both where the YAML stream slice ends and where the body
357    // begins.
358    let bytes = content.as_bytes();
359    let mut candidates: Vec<usize> = Vec::new();
360    for (i, &b) in bytes.iter().enumerate() {
361        if b != b'\n' {
362            continue;
363        }
364        let line_start = i + 1;
365        if line_start + 3 > bytes.len() {
366            continue;
367        }
368        if &bytes[line_start..line_start + 3] != b"---" {
369            continue;
370        }
371        let after_dashes = line_start + 3;
372        let end = if after_dashes == bytes.len() {
373            after_dashes
374        } else if bytes[after_dashes] == b'\n' {
375            after_dashes + 1
376        } else if after_dashes + 1 < bytes.len()
377            && bytes[after_dashes] == b'\r'
378            && bytes[after_dashes + 1] == b'\n'
379        {
380            after_dashes + 2
381        } else {
382            // Line is something like `---trailing` or `----`; not a candidate.
383            continue;
384        };
385        candidates.push(end);
386    }
387
388    if candidates.is_empty() {
389        anyhow::bail!("SKILL.md missing closing frontmatter delimiter (---)");
390    }
391
392    // Try each candidate in order: slice content up through the candidate's
393    // terminator and ask `serde_yaml_ng` to parse it as a YAML stream. If the
394    // first document deserializes into `SkillMetadata`, that candidate is the
395    // real closer. Otherwise an earlier candidate may have cut the YAML in the
396    // middle of a scalar / quoted string; try the next one.
397    let mut last_error: Option<anyhow::Error> = None;
398    for end in candidates {
399        let prefix = &content[..end];
400        let mut docs = serde_yaml_ng::Deserializer::from_str(prefix);
401        let Some(first_doc) = docs.next() else {
402            continue;
403        };
404        match SkillMetadata::deserialize(first_doc) {
405            Ok(metadata) => return Ok((metadata, &content[end..])),
406            Err(e) => last_error = Some(anyhow::Error::new(e)),
407        }
408    }
409
410    Err(last_error
411        .unwrap_or_else(|| anyhow::anyhow!("could not parse YAML frontmatter"))
412        .context("Invalid YAML frontmatter"))
413}
414
415/// Maximum length for a valid skill name. Mirrors the upper bound enforced
416/// by [`validate_name`].
417pub const MAX_SKILL_NAME_LEN: usize = 64;
418
419/// Maximum recommended length (in bytes) for a skill description. The
420/// create-skill UI enforces this as a hard limit, while the loader emits a
421/// warning and still loads longer descriptions.
422///
423/// Byte-based rather than char-based because that's what `.len()` returns
424/// and what every caller currently measures; the UI also surfaces this
425/// limit as a byte count so the editor's counter matches the validator.
426pub const MAX_SKILL_DESCRIPTION_LEN: usize = 1024;
427
428/// Convert an arbitrary human-readable string into a valid skill name, or
429/// return `None` if no valid name can be produced (e.g. the input contains
430/// no ASCII alphanumeric characters at all).
431///
432/// The transformation:
433///
434/// 1. Replaces each `&` with the word `and` (with separators on either
435///    side), so titles like "rock & roll" or "AT&T" round-trip something
436///    meaningful (`rock-and-roll`, `at-and-t`) rather than dropping the
437///    `&` and silently mashing the neighbours together.
438/// 2. ASCII-lowercases every ASCII letter.
439/// 3. Replaces each space with `-`. Existing `-` characters are kept.
440/// 4. **Drops** every other non-alphanumeric character entirely (NOT
441///    replaced with a dash). So `foo!bar` slugifies to `foobar`, not
442///    `foo-bar` — only word boundaries the user actually wrote (spaces)
443///    become dashes.
444/// 5. Collapses runs of `-` into a single `-`.
445/// 6. Trims leading and trailing `-`.
446/// 7. Truncates to [`MAX_SKILL_NAME_LEN`] bytes (then re-trims trailing `-`
447///    in case the truncation landed on one).
448///
449/// The result, if `Some`, always satisfies [`validate_name`].
450pub fn slugify_skill_name(input: &str) -> Option<String> {
451    // Substitute `&` with `-and-` BEFORE the per-character pass; the
452    // existing dash-collapsing and edge-trimming logic then handles the
453    // boundary cases (`foo & bar`, `&foo`, `foo&`, `&&`, etc.) for free.
454    let input = input.replace('&', "-and-");
455    let mut slug = String::with_capacity(input.len());
456    let mut last_was_dash = true; // suppress a leading `-`
457    for ch in input.chars() {
458        let mapped = if ch.is_ascii_alphanumeric() {
459            Some(ch.to_ascii_lowercase())
460        } else if ch == ' ' || ch == '-' {
461            Some('-')
462        } else {
463            // Drop the character entirely — and importantly, do NOT touch
464            // `last_was_dash`. That way `foo!bar` stays one run of
465            // alphanumerics (`foobar`) rather than getting a fake
466            // separator inserted (`foo-bar`).
467            None
468        };
469        let Some(c) = mapped else { continue };
470        if c == '-' {
471            if last_was_dash {
472                continue;
473            }
474            last_was_dash = true;
475        } else {
476            last_was_dash = false;
477        }
478        slug.push(c);
479    }
480    if slug.ends_with('-') {
481        slug.pop();
482    }
483    if slug.len() > MAX_SKILL_NAME_LEN {
484        slug.truncate(MAX_SKILL_NAME_LEN);
485        while slug.ends_with('-') {
486            slug.pop();
487        }
488    }
489    if slug.is_empty() { None } else { Some(slug) }
490}
491
492/// Validate a skill name against the rules enforced by both the loader
493/// and the create-skill UI.
494///
495/// Rules:
496/// * non-empty
497/// * at most [`MAX_SKILL_NAME_LEN`] bytes
498/// * ASCII lowercase letters, digits, and hyphens only
499/// * must not start or end with a hyphen — [`slugify_skill_name`]
500///   already guarantees this for its output, so requiring it in the
501///   validator keeps hand-written `SKILL.md` files consistent with
502///   slugifier output
503///
504/// Error messages are returned as `&'static str` (interpolated at
505/// compile time via `formatcp!`) so that UI surfaces can store them in
506/// `Option<&'static str>` fields without allocating, and loader callers
507/// can convert them to `anyhow::Error` via `anyhow::Error::msg`.
508pub fn validate_name(name: &str) -> Result<(), &'static str> {
509    if name.is_empty() {
510        return Err("Skill name cannot be empty");
511    }
512    if name.len() > MAX_SKILL_NAME_LEN {
513        return Err(formatcp!(
514            "Skill name must be at most {MAX_SKILL_NAME_LEN} characters"
515        ));
516    }
517    if name.starts_with('-') || name.ends_with('-') {
518        return Err("Skill name must not start or end with a hyphen");
519    }
520    if !name
521        .chars()
522        .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
523    {
524        return Err("Skill name must contain only lowercase letters, numbers, and hyphens");
525    }
526    Ok(())
527}
528
529/// Validate a skill description against the strict rules enforced by the
530/// create-skill UI and imported/shared skill parsing.
531pub fn validate_description(description: &str) -> Result<(), &'static str> {
532    if description.trim().is_empty() {
533        return Err("Skill description cannot be empty");
534    }
535    if description.len() > MAX_SKILL_DESCRIPTION_LEN {
536        return Err(formatcp!(
537            "Skill description must be at most {MAX_SKILL_DESCRIPTION_LEN} bytes"
538        ));
539    }
540    Ok(())
541}
542
543pub async fn load_skills_from_directory(
544    fs: &Arc<dyn Fs>,
545    directory: &Path,
546    source: SkillSource,
547) -> Vec<Result<Skill, SkillLoadError>> {
548    if !fs.is_dir(directory).await {
549        return Vec::new();
550    }
551
552    let skill_files = find_skill_files(fs, directory).await;
553
554    let mut results: Vec<Result<Skill, SkillLoadError>> = futures::stream::iter(skill_files)
555        .map(|path| {
556            let fs = fs.clone();
557            let source = source.clone();
558            async move { load_skill_frontmatter(fs, path, source).await }
559        })
560        .buffer_unordered(SKILL_IO_CONCURRENCY)
561        .collect()
562        .await;
563
564    // Sort by path so name-conflict resolution in `apply_skill_overrides`
565    // is deterministic — `fs.read_dir` order is filesystem-dependent.
566    results.sort_by(|a, b| {
567        let path_a: &Path = match a {
568            Ok(skill) => &skill.skill_file_path,
569            Err(error) => &error.path,
570        };
571        let path_b: &Path = match b {
572            Ok(skill) => &skill.skill_file_path,
573            Err(error) => &error.path,
574        };
575        path_a.cmp(path_b)
576    });
577
578    results
579}
580
581/// Find every `<skills_root>/<name>/SKILL.md` directly under `directory`.
582///
583/// Discovery is intentionally one level deep: a skill is the immediate
584/// child directory of the skills root, and `SKILL.md` is the file that
585/// names it. See `crates/agent_skills/README.md` for why we don't recurse.
586async fn find_skill_files(fs: &Arc<dyn Fs>, directory: &Path) -> Vec<PathBuf> {
587    let Ok(mut entries) = fs.read_dir(directory).await else {
588        return Vec::new();
589    };
590
591    let mut entry_paths = Vec::new();
592    while let Some(entry) = entries.next().await {
593        if let Ok(entry_path) = entry {
594            entry_paths.push(entry_path);
595        }
596    }
597
598    futures::stream::iter(entry_paths)
599        .map(|entry_path| {
600            let fs = fs.clone();
601            async move {
602                let Ok(Some(metadata)) = fs.metadata(&entry_path).await else {
603                    return None;
604                };
605                if !metadata.is_dir {
606                    return None;
607                }
608                let skill_file = entry_path.join(SKILL_FILE_NAME);
609                fs.is_file(&skill_file).await.then_some(skill_file)
610            }
611        })
612        .buffer_unordered(SKILL_IO_CONCURRENCY)
613        .filter_map(|x| async move { x })
614        .collect()
615        .await
616}
617
618/// Read `skill_file_path` from disk and parse its frontmatter. The
619/// SKILL.md body is parsed away by `parse_skill_frontmatter` and not
620/// surfaced here; it's re-read on demand via `read_skill_body` when a
621/// skill is actually being loaded for the model.
622///
623/// We load the whole file in one go rather than streaming up to the
624/// closing `---`. `MAX_SKILL_FILE_SIZE` is 100KB and the metadata check
625/// below caps the worst case at that, so the peak transient cost is
626/// trivially small (≤ `MAX_SKILL_FILE_SIZE` × `SKILL_IO_CONCURRENCY`).
627pub async fn load_skill_frontmatter(
628    fs: Arc<dyn Fs>,
629    skill_file_path: PathBuf,
630    source: SkillSource,
631) -> Result<Skill, SkillLoadError> {
632    // Short-circuit on oversized files before reading any of their
633    // contents, so a stray multi-GB file named `SKILL.md` can't OOM the
634    // app. If metadata is unavailable, refuse to read.
635    let metadata = fs
636        .metadata(&skill_file_path)
637        .await
638        .map_err(|e| SkillLoadError {
639            path: skill_file_path.clone(),
640            message: format!("Failed to read SKILL.md metadata: {}", e),
641        })?;
642    if let Some(metadata) = metadata
643        && metadata.len > MAX_SKILL_FILE_SIZE as u64
644    {
645        return Err(SkillLoadError {
646            path: skill_file_path.clone(),
647            message: format!(
648                "SKILL.md file exceeds maximum size of {}KB",
649                MAX_SKILL_FILE_SIZE / 1024
650            ),
651        });
652    }
653
654    let content = fs
655        .load(&skill_file_path)
656        .await
657        .map_err(|e| SkillLoadError {
658            path: skill_file_path.clone(),
659            message: format!("Failed to read file: {}", e),
660        })?;
661
662    parse_skill_frontmatter(&skill_file_path, &content, source).map_err(|e| SkillLoadError {
663        path: skill_file_path.clone(),
664        message: e.to_string(),
665    })
666}
667
668/// Read the body of a SKILL.md from disk — everything after the closing
669/// `---`. Called only when a skill is being materialized for the model
670/// (via `SkillTool` or a slash invocation). The body is intentionally
671/// NOT kept in memory between materializations.
672pub async fn read_skill_body(
673    fs: &dyn Fs,
674    skill_file_path: &Path,
675) -> Result<String, SkillLoadError> {
676    let content = fs.load(skill_file_path).await.map_err(|e| SkillLoadError {
677        path: skill_file_path.to_path_buf(),
678        message: format!("Failed to read file: {}", e),
679    })?;
680
681    read_skill_body_from_content(skill_file_path, &content)
682}
683
684pub fn read_skill_body_from_content(
685    skill_file_path: &Path,
686    content: &str,
687) -> Result<String, SkillLoadError> {
688    let (_metadata, body, _load_warnings) =
689        parse_skill_file_content_for_loading(content).map_err(|e| SkillLoadError {
690            path: skill_file_path.to_path_buf(),
691            message: e.to_string(),
692        })?;
693
694    Ok(body.trim().to_string())
695}
696
697/// Content of the built-in `create-skill` SKILL.md, embedded at compile time.
698const CREATE_SKILL_CONTENT: &str = include_str!("builtin/create-skill/SKILL.md");
699
700/// Returns the set of skills that are compiled into the Zed binary.
701pub fn builtin_skills() -> Vec<Skill> {
702    let mut skills = Vec::new();
703    if let Ok(skill) = parse_builtin_skill("create-skill", CREATE_SKILL_CONTENT) {
704        skills.push(skill);
705    }
706    skills
707}
708
709/// Parse a built-in skill from its embedded SKILL.md content. The skill
710/// gets a synthetic `<built-in>` path since it doesn't live on disk.
711fn parse_builtin_skill(name: &str, content: &'static str) -> Result<Skill> {
712    let (metadata, body) = extract_frontmatter(content)?;
713    validate_name(&metadata.name).map_err(anyhow::Error::msg)?;
714    validate_description(&metadata.description).map_err(anyhow::Error::msg)?;
715
716    let synthetic_dir = PathBuf::from(format!("<built-in>/{}", name));
717    let synthetic_path = synthetic_dir.join(SKILL_FILE_NAME);
718
719    Ok(Skill {
720        name: metadata.name,
721        description: metadata.description,
722        source: SkillSource::BuiltIn,
723        directory_path: synthetic_dir,
724        skill_file_path: synthetic_path,
725        load_warnings: Vec::new(),
726        disable_model_invocation: metadata.disable_model_invocation,
727        embedded_body: Some(body.trim()),
728    })
729}
730
731/// All built-in skills as `(name, raw_content)` pairs. Used by
732/// `builtin_skill_content` to serve the full SKILL.md without disk I/O.
733const BUILTIN_SKILL_ENTRIES: &[(&str, &str)] = &[("create-skill", CREATE_SKILL_CONTENT)];
734
735/// Look up the full embedded content of a built-in skill by its
736/// synthetic file path. Returns `None` if the path doesn't match any
737/// built-in skill.
738pub fn builtin_skill_content(skill_file_path: &Path) -> Option<&'static str> {
739    BUILTIN_SKILL_ENTRIES.iter().find_map(|(name, content)| {
740        let expected = PathBuf::from(format!("<built-in>/{}", name)).join(SKILL_FILE_NAME);
741        (expected == skill_file_path).then_some(*content)
742    })
743}
744
745/// Returns the global skills directory: `~/.agents/skills`.
746///
747/// Other agents (e.g. Claude Code) already write skill files into this
748/// location, so a Zed installation may have skills here even before the
749/// rest of Zed's skills support ships.
750///
751/// In test builds, `paths::home_dir()` is hardcoded to a fixed path
752/// (e.g. `/Users/zed`), so all tests using this function operate on the
753/// same simulated home directory. Each test should use its own `FakeFs`
754/// instance to keep skill setups from leaking across tests.
755pub fn global_skills_dir() -> PathBuf {
756    paths::home_dir()
757        .join(AGENTS_DIR_NAME)
758        .join(SKILLS_DIR_NAME)
759}
760
761/// Project-local skills live at this path relative to a worktree root,
762/// e.g. `<worktree>/.agents/skills/<skill>/SKILL.md`.
763pub fn project_skills_relative_path() -> &'static str {
764    ".agents/skills"
765}
766
767/// Returns `true` if `path` looks like it points into an agent skills
768/// directory — i.e. it contains `AGENTS_DIR_NAME` immediately followed by
769/// `SKILLS_DIR_NAME` as two consecutive path components, anywhere in the
770/// path. Comparison is case-insensitive so it agrees with classifiers
771/// that canonicalize against `~/.agents/skills` on case-insensitive
772/// filesystems (macOS/Windows by default).
773///
774/// The path arriving here can be any of:
775///
776///   1. Bare relative-to-worktree-root: `.agents/skills/...`
777///   2. Worktree-name prefixed:         `<worktree>/.agents/skills/...`
778///   3. Absolute:                       `/path/to/worktree/.agents/skills/...`
779///
780/// Any-depth matching has a known cost: a `.agents/skills` directory
781/// nested inside vendored sources (e.g. `vendor/x/.agents/skills/...`)
782/// would also be flagged. We accept that as the safer-failing direction —
783/// an extra confirmation prompt for a vendored file is annoying, while
784/// silently letting the agent overwrite a `.agents/skills` tree the user
785/// didn't expect to be touched is unsafe.
786pub fn is_agents_skills_path(path: &Path) -> bool {
787    let mut components = path.components().map(|c| c.as_os_str());
788    let Some(mut prev) = components.next() else {
789        return false;
790    };
791    for curr in components {
792        if component_matches_ignore_ascii_case(prev, AGENTS_DIR_NAME)
793            && component_matches_ignore_ascii_case(curr, SKILLS_DIR_NAME)
794        {
795            return true;
796        }
797        prev = curr;
798    }
799    false
800}
801
802/// The `zed://` scheme used by share links.
803const SKILL_SHARE_LINK_SCHEME: &str = "zed";
804/// The host (the part after `zed://`) that identifies a skill share link.
805const SKILL_SHARE_LINK_HOST: &str = "skill";
806/// The query parameter that carries the embedded `SKILL.md` payload.
807const SKILL_SHARE_LINK_DATA_PARAM: &str = "data";
808
809/// The `zed://` deep-link prefix for a shared skill. Opening a link with this
810/// prefix prompts the recipient to review and install the embedded skill.
811pub const SKILL_SHARE_LINK_PREFIX: &str =
812    concatcp!(SKILL_SHARE_LINK_SCHEME, "://", SKILL_SHARE_LINK_HOST);
813
814/// Build a shareable `zed://skill?data=…` link that fully embeds the given
815/// `SKILL.md` file contents.
816///
817/// The contents are base64url-encoded (no padding) so the link is
818/// self-contained and URL-safe: the recipient doesn't need the skill to be
819/// hosted anywhere. Recover the contents with [`decode_skill_share_link`].
820pub fn encode_skill_share_link(skill_file_content: &str) -> String {
821    use base64::Engine as _;
822    let data =
823        base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(skill_file_content.as_bytes());
824    let mut url = Url::parse(SKILL_SHARE_LINK_PREFIX).expect("skill share link prefix is valid");
825    url.query_pairs_mut()
826        .append_pair(SKILL_SHARE_LINK_DATA_PARAM, &data);
827    url.into()
828}
829
830/// Recover the `SKILL.md` contents embedded in a `zed://skill?data=…` link
831/// produced by [`encode_skill_share_link`].
832pub fn decode_skill_share_link(link: &str) -> Result<String> {
833    use base64::Engine as _;
834    let url = Url::parse(link).context("skill share link is not a valid URL")?;
835    anyhow::ensure!(
836        url.scheme() == SKILL_SHARE_LINK_SCHEME && url.host_str() == Some(SKILL_SHARE_LINK_HOST),
837        "not a skill share link"
838    );
839    let data = url
840        .query_pairs()
841        .find_map(|(key, value)| (key == SKILL_SHARE_LINK_DATA_PARAM).then_some(value))
842        .context("skill share link is missing the `data` parameter")?;
843    let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
844        .decode(data.as_bytes())
845        .context("skill share link `data` is not valid base64")?;
846    anyhow::ensure!(
847        bytes.len() <= MAX_SKILL_FILE_SIZE,
848        "shared skill exceeds the maximum size of {MAX_SKILL_FILE_SIZE} bytes"
849    );
850    let content = String::from_utf8(bytes).context("skill share link `data` is not valid UTF-8")?;
851    Ok(content)
852}
853
854#[cfg(test)]
855mod tests {
856    use super::*;
857    use fs::FakeFs;
858    use gpui::TestAppContext;
859
860    #[test]
861    fn test_skill_source_precedence_is_total_and_ordered() {
862        // Pin the hierarchy: project-local > global > built-in. Every
863        // override and conflict-resolution site routes through this,
864        // so the rest of the codebase relies on it being correct.
865        let built_in = SkillSource::BuiltIn.precedence();
866        let global = SkillSource::Global.precedence();
867        let project = SkillSource::ProjectLocal {
868            worktree_id: SkillScopeId(1),
869            worktree_root_name: "my-project".into(),
870        }
871        .precedence();
872
873        assert!(built_in < global, "global must shadow built-in");
874        assert!(global < project, "project-local must shadow global");
875
876        // Two project-local skills from different worktrees tie. The
877        // "first wins" convention is enforced by the callers, but the
878        // precedence itself must be equal so neither silently shadows
879        // the other.
880        let other_project = SkillSource::ProjectLocal {
881            worktree_id: SkillScopeId(2),
882            worktree_root_name: "other-project".into(),
883        }
884        .precedence();
885        assert_eq!(project, other_project);
886    }
887
888    #[test]
889    fn test_parse_valid_skill() {
890        let content = r#"---
891name: my-skill
892description: A test skill for testing purposes
893---
894
895# My Skill
896
897## Instructions
898Do the thing.
899"#;
900
901        let result = parse_skill_frontmatter(
902            Path::new("/skills/my-skill/SKILL.md"),
903            content,
904            SkillSource::Global,
905        );
906        let skill = result.expect("Should parse successfully");
907
908        assert_eq!(skill.name, "my-skill");
909        assert_eq!(skill.description, "A test skill for testing purposes");
910        assert_eq!(skill.directory_path, Path::new("/skills/my-skill"));
911        // Default: skill is invocable by both model and user.
912        assert!(!skill.disable_model_invocation);
913    }
914
915    #[test]
916    fn test_parse_skill_file_content_returns_body() {
917        let content = r#"---
918name: my-skill
919description: A test skill for testing purposes
920---
921
922# My Skill
923
924Do the thing.
925"#;
926
927        let (metadata, body) = parse_skill_file_content(content)
928            .expect("valid skill content should parse successfully");
929
930        assert_eq!(metadata.name, "my-skill");
931        assert_eq!(metadata.description, "A test skill for testing purposes");
932        assert_eq!(body.trim(), "# My Skill\n\nDo the thing.");
933    }
934
935    #[test]
936    fn test_parse_disable_model_invocation_true() {
937        let content = r#"---
938name: deploy
939description: Deploy the application to production.
940disable-model-invocation: true
941---
942
943Steps to deploy.
944"#;
945
946        let skill = parse_skill_frontmatter(
947            Path::new("/skills/deploy/SKILL.md"),
948            content,
949            SkillSource::Global,
950        )
951        .expect("should parse");
952        assert!(skill.disable_model_invocation);
953    }
954
955    #[test]
956    fn test_parse_disable_model_invocation_explicit_false() {
957        let content = r#"---
958name: helper
959description: A helper skill.
960disable-model-invocation: false
961---
962
963Help.
964"#;
965
966        let skill = parse_skill_frontmatter(
967            Path::new("/skills/helper/SKILL.md"),
968            content,
969            SkillSource::Global,
970        )
971        .expect("should parse");
972        assert!(!skill.disable_model_invocation);
973    }
974
975    #[test]
976    fn test_parse_missing_frontmatter() {
977        let content = "# My Skill\n\nNo frontmatter here.";
978
979        let result = parse_skill_frontmatter(
980            Path::new("/skills/test/SKILL.md"),
981            content,
982            SkillSource::Global,
983        );
984        assert!(result.is_err());
985        assert!(
986            result
987                .unwrap_err()
988                .to_string()
989                .contains("must start with YAML frontmatter")
990        );
991    }
992
993    #[test]
994    fn test_parse_missing_closing_delimiter() {
995        let content = r#"---
996name: test
997description: Test
998# No closing delimiter
999"#;
1000
1001        let result = parse_skill_frontmatter(
1002            Path::new("/skills/test/SKILL.md"),
1003            content,
1004            SkillSource::Global,
1005        );
1006        assert!(result.is_err());
1007        assert!(
1008            result
1009                .unwrap_err()
1010                .to_string()
1011                .contains("missing closing frontmatter delimiter")
1012        );
1013    }
1014
1015    #[test]
1016    fn test_parse_empty_frontmatter_closing_on_next_line() {
1017        // An empty frontmatter (closer immediately after the opener) is a real
1018        // authoring case. Parsing should ultimately fail because the empty YAML
1019        // doc lacks `name` and `description`, but the error must be the proper
1020        // YAML/missing-field error rather than "missing closing frontmatter
1021        // delimiter" — the closer is right there.
1022        let content = "---\n---\nbody\n";
1023
1024        let result = parse_skill_frontmatter(
1025            Path::new("/skills/test/SKILL.md"),
1026            content,
1027            SkillSource::Global,
1028        );
1029        assert!(result.is_err());
1030        let err = result.unwrap_err();
1031        let err_chain = format!("{:?}", err);
1032        assert!(
1033            !err_chain.contains("missing closing frontmatter delimiter"),
1034            "Error should NOT be the missing-closer error since the closer is present: {}",
1035            err_chain
1036        );
1037        assert!(
1038            err_chain.contains("missing field")
1039                || err_chain.contains("name")
1040                || err_chain.contains("description")
1041                || err_chain.contains("Invalid YAML"),
1042            "Error should mention missing name/description field or invalid YAML: {}",
1043            err_chain
1044        );
1045    }
1046
1047    #[test]
1048    fn test_parse_missing_name() {
1049        let content = r#"---
1050description: A test skill
1051---
1052
1053Content here.
1054"#;
1055
1056        let result = parse_skill_frontmatter(
1057            Path::new("/skills/test/SKILL.md"),
1058            content,
1059            SkillSource::Global,
1060        );
1061        assert!(result.is_err());
1062        let err = result.unwrap_err();
1063        let err_chain = format!("{:?}", err);
1064        assert!(
1065            err_chain.contains("missing field")
1066                || err_chain.contains("name")
1067                || err_chain.contains("Invalid YAML"),
1068            "Error should mention missing name field or invalid YAML: {}",
1069            err_chain
1070        );
1071    }
1072
1073    #[test]
1074    fn test_parse_missing_description() {
1075        let content = r#"---
1076name: test-skill
1077---
1078
1079Content here.
1080"#;
1081
1082        let result = parse_skill_frontmatter(
1083            Path::new("/skills/test/SKILL.md"),
1084            content,
1085            SkillSource::Global,
1086        );
1087        assert!(result.is_err());
1088        let err = result.unwrap_err();
1089        let err_chain = format!("{:?}", err);
1090        assert!(
1091            err_chain.contains("missing field")
1092                || err_chain.contains("description")
1093                || err_chain.contains("Invalid YAML"),
1094            "Error should mention missing description field or invalid YAML: {}",
1095            err_chain
1096        );
1097    }
1098
1099    #[test]
1100    fn test_parse_name_too_long() {
1101        let long_name = "a".repeat(65);
1102        let content = format!(
1103            r#"---
1104name: {long_name}
1105description: Test
1106---
1107
1108Content.
1109"#
1110        );
1111
1112        let result = parse_skill_frontmatter(
1113            Path::new("/skills/test/SKILL.md"),
1114            &content,
1115            SkillSource::Global,
1116        );
1117        assert!(result.is_err());
1118        let expected = format!("at most {MAX_SKILL_NAME_LEN} characters");
1119        assert!(result.unwrap_err().to_string().contains(&expected));
1120    }
1121
1122    #[test]
1123    fn test_parse_name_invalid_chars() {
1124        let content = r#"---
1125name: My_Skill
1126description: Test
1127---
1128
1129Content.
1130"#;
1131
1132        let result = parse_skill_frontmatter(
1133            Path::new("/skills/test/SKILL.md"),
1134            content,
1135            SkillSource::Global,
1136        );
1137        assert!(result.is_err());
1138        assert!(
1139            result
1140                .unwrap_err()
1141                .to_string()
1142                .contains("lowercase letters, numbers, and hyphens")
1143        );
1144    }
1145
1146    #[test]
1147    fn test_slugify_basic() {
1148        assert_eq!(
1149            slugify_skill_name("My Cool Skill").as_deref(),
1150            Some("my-cool-skill")
1151        );
1152    }
1153
1154    #[test]
1155    fn test_slugify_strips_invalid_chars() {
1156        // Punctuation is dropped; spaces between words still produce dashes.
1157        // `Hello,` → `hello`, then `␣` → `-`, then `World!` → `world`, etc.
1158        assert_eq!(
1159            slugify_skill_name("Hello, World! (v2)").as_deref(),
1160            Some("hello-world-v2")
1161        );
1162    }
1163
1164    #[test]
1165    fn test_slugify_drops_punctuation_in_middle_no_spaces() {
1166        // Punctuation between alphanumerics is dropped entirely — it does
1167        // NOT become a dash. Only user-written spaces become dashes.
1168        assert_eq!(slugify_skill_name("foo!bar").as_deref(), Some("foobar"));
1169        assert_eq!(slugify_skill_name("foo?bar").as_deref(), Some("foobar"));
1170        assert_eq!(slugify_skill_name("foo%bar").as_deref(), Some("foobar"));
1171        assert_eq!(slugify_skill_name("100%sure").as_deref(), Some("100sure"));
1172        assert_eq!(
1173            slugify_skill_name("what's that").as_deref(),
1174            Some("whats-that")
1175        );
1176        // `&` is special-cased to become `and` — see
1177        // `test_slugify_ampersand_becomes_and` for the full coverage.
1178        assert_eq!(
1179            slugify_skill_name("don't&won't").as_deref(),
1180            Some("dont-and-wont")
1181        );
1182    }
1183
1184    #[test]
1185    fn test_slugify_ampersand_becomes_and() {
1186        // No spaces around `&`.
1187        assert_eq!(
1188            slugify_skill_name("foo&bar").as_deref(),
1189            Some("foo-and-bar")
1190        );
1191        assert_eq!(
1192            slugify_skill_name("rock&roll").as_deref(),
1193            Some("rock-and-roll")
1194        );
1195        // Spaces around `&`: collapses to a single dash on each side.
1196        assert_eq!(
1197            slugify_skill_name("foo & bar").as_deref(),
1198            Some("foo-and-bar")
1199        );
1200        // Asymmetric spacing.
1201        assert_eq!(
1202            slugify_skill_name("foo& bar").as_deref(),
1203            Some("foo-and-bar")
1204        );
1205        assert_eq!(
1206            slugify_skill_name("foo &bar").as_deref(),
1207            Some("foo-and-bar")
1208        );
1209        // Leading/trailing `&`: the substituted spaces become leading/
1210        // trailing dashes which then get trimmed.
1211        assert_eq!(slugify_skill_name("&foo").as_deref(), Some("and-foo"));
1212        assert_eq!(slugify_skill_name("foo&").as_deref(), Some("foo-and"));
1213        // `&` alone slugifies to the word `and`, not to `None`.
1214        assert_eq!(slugify_skill_name("&").as_deref(), Some("and"));
1215        assert_eq!(slugify_skill_name(" & ").as_deref(), Some("and"));
1216        // Multiple `&`s with various spacing all collapse properly.
1217        assert_eq!(slugify_skill_name("&&").as_deref(), Some("and-and"));
1218        assert_eq!(
1219            slugify_skill_name("foo & & bar").as_deref(),
1220            Some("foo-and-and-bar")
1221        );
1222        // Mixed with other punctuation (other punctuation is still dropped).
1223        assert_eq!(slugify_skill_name("AT&T").as_deref(), Some("at-and-t"));
1224        assert_eq!(slugify_skill_name("Q&A!").as_deref(), Some("q-and-a"));
1225    }
1226
1227    #[test]
1228    fn test_slugify_punctuation_surrounded_by_spaces() {
1229        // `foo ! bar` → `foo-bar`: the two spaces would each produce a
1230        // dash, but consecutive dashes are collapsed.
1231        assert_eq!(slugify_skill_name("foo ! bar").as_deref(), Some("foo-bar"));
1232        assert_eq!(slugify_skill_name("foo ? bar").as_deref(), Some("foo-bar"));
1233        assert_eq!(
1234            slugify_skill_name("100 % sure").as_deref(),
1235            Some("100-sure")
1236        );
1237        assert_eq!(
1238            slugify_skill_name("foo @ bar @ baz").as_deref(),
1239            Some("foo-bar-baz")
1240        );
1241    }
1242
1243    #[test]
1244    fn test_slugify_punctuation_adjacent_to_space() {
1245        // `foo! bar` and `foo !bar` both produce `foo-bar` — the
1246        // punctuation contributes nothing, the single space contributes
1247        // the dash.
1248        assert_eq!(slugify_skill_name("foo! bar").as_deref(), Some("foo-bar"));
1249        assert_eq!(slugify_skill_name("foo !bar").as_deref(), Some("foo-bar"));
1250        assert_eq!(slugify_skill_name("foo? bar").as_deref(), Some("foo-bar"));
1251    }
1252
1253    #[test]
1254    fn test_slugify_leading_and_trailing_punctuation() {
1255        // Punctuation at the edges is dropped; there's no leading/trailing
1256        // dash to trim because the punctuation never became a dash in the
1257        // first place.
1258        assert_eq!(slugify_skill_name("!foo").as_deref(), Some("foo"));
1259        assert_eq!(slugify_skill_name("foo!").as_deref(), Some("foo"));
1260        assert_eq!(slugify_skill_name("!!!foo!!!").as_deref(), Some("foo"));
1261        assert_eq!(slugify_skill_name("?foo?").as_deref(), Some("foo"));
1262        assert_eq!(slugify_skill_name("...foo...").as_deref(), Some("foo"));
1263    }
1264
1265    #[test]
1266    fn test_slugify_only_punctuation_returns_none() {
1267        assert_eq!(slugify_skill_name("!!!"), None);
1268        assert_eq!(slugify_skill_name("?@$"), None);
1269        assert_eq!(slugify_skill_name("()[]{}"), None);
1270        assert_eq!(slugify_skill_name(".,;:"), None);
1271    }
1272
1273    #[test]
1274    fn test_slugify_mixed_punctuation_spaces_and_dashes() {
1275        // A messy realistic input: combination of punctuation, spaces,
1276        // existing dashes, and casing.
1277        assert_eq!(
1278            slugify_skill_name("  -- Hello, World!! -- ").as_deref(),
1279            Some("hello-world")
1280        );
1281        assert_eq!(
1282            slugify_skill_name("C++ vs. Rust?").as_deref(),
1283            Some("c-vs-rust")
1284        );
1285        assert_eq!(
1286            slugify_skill_name("v1.2.3-beta").as_deref(),
1287            Some("v123-beta")
1288        );
1289    }
1290
1291    #[test]
1292    fn test_slugify_underscores_are_dropped() {
1293        // Underscores aren't a valid skill-name character and aren't
1294        // separators — only spaces become dashes — so underscores get
1295        // dropped entirely.
1296        assert_eq!(slugify_skill_name("foo_bar").as_deref(), Some("foobar"));
1297        assert_eq!(slugify_skill_name("FOO_BAR").as_deref(), Some("foobar"));
1298        assert_eq!(
1299            slugify_skill_name("snake_case style").as_deref(),
1300            Some("snakecase-style")
1301        );
1302    }
1303
1304    #[test]
1305    fn test_slugify_collapses_consecutive_dashes() {
1306        assert_eq!(
1307            slugify_skill_name("foo   ---  bar").as_deref(),
1308            Some("foo-bar")
1309        );
1310    }
1311
1312    #[test]
1313    fn test_slugify_trims_leading_and_trailing_dashes() {
1314        assert_eq!(slugify_skill_name("---foo---").as_deref(), Some("foo"));
1315        assert_eq!(slugify_skill_name("  foo  ").as_deref(), Some("foo"));
1316    }
1317
1318    #[test]
1319    fn test_slugify_lowercases() {
1320        assert_eq!(slugify_skill_name("FOO BAR").as_deref(), Some("foo-bar"));
1321        assert_eq!(
1322            slugify_skill_name("MyCoolSkill").as_deref(),
1323            Some("mycoolskill")
1324        );
1325    }
1326
1327    #[test]
1328    fn test_slugify_strips_non_ascii_letters() {
1329        // Non-ASCII chars are replaced with `-`, then collapsed.
1330        assert_eq!(slugify_skill_name("abc\u{00e9}").as_deref(), Some("abc"));
1331        assert_eq!(slugify_skill_name("\u{4e2d}\u{6587}"), None);
1332    }
1333
1334    #[test]
1335    fn test_slugify_returns_none_for_empty_or_unmappable() {
1336        assert_eq!(slugify_skill_name(""), None);
1337        assert_eq!(slugify_skill_name("   "), None);
1338        assert_eq!(slugify_skill_name("!!!"), None);
1339        assert_eq!(slugify_skill_name("---"), None);
1340    }
1341
1342    #[test]
1343    fn test_slugify_truncates_long_inputs() {
1344        let input = "a".repeat(200);
1345        let slug = slugify_skill_name(&input).expect("should slugify");
1346        assert_eq!(slug.len(), MAX_SKILL_NAME_LEN);
1347        assert!(slug.chars().all(|c| c == 'a'));
1348    }
1349
1350    #[test]
1351    fn test_slugify_truncation_does_not_leave_trailing_dash() {
1352        // The 64th byte lands on a `-`, which we must strip post-truncation.
1353        let mut input = "a".repeat(63);
1354        input.push_str(" extra");
1355        let slug = slugify_skill_name(&input).expect("should slugify");
1356        assert!(!slug.ends_with('-'));
1357        assert!(slug.len() <= MAX_SKILL_NAME_LEN);
1358    }
1359
1360    #[test]
1361    fn test_slugify_output_passes_validate_name() {
1362        for input in [
1363            "My Cool Skill",
1364            "Hello, World!",
1365            "---foo---",
1366            "123 abc",
1367            "a".repeat(200).as_str(),
1368        ] {
1369            let slug = slugify_skill_name(input).expect("should slugify");
1370            validate_name(&slug).unwrap_or_else(|err| {
1371                panic!("slug {slug:?} from {input:?} failed validation: {err}")
1372            });
1373        }
1374    }
1375
1376    #[test]
1377    fn test_parse_description_too_long_loads_with_warning() {
1378        let long_desc = "a".repeat(MAX_SKILL_DESCRIPTION_LEN + 1);
1379        let content = format!(
1380            r#"---
1381name: test
1382description: {long_desc}
1383---
1384
1385Content.
1386"#
1387        );
1388
1389        let skill = parse_skill_frontmatter(
1390            Path::new("/skills/test/SKILL.md"),
1391            &content,
1392            SkillSource::Global,
1393        )
1394        .expect("long descriptions should load with a warning");
1395
1396        assert_eq!(skill.description, long_desc);
1397        assert_eq!(skill.load_warnings.len(), 1);
1398        assert_eq!(
1399            skill.load_warnings[0],
1400            SkillLoadWarning::DescriptionTooLong {
1401                actual_len: MAX_SKILL_DESCRIPTION_LEN + 1,
1402                max_len: MAX_SKILL_DESCRIPTION_LEN,
1403            }
1404        );
1405    }
1406
1407    #[test]
1408    fn test_parse_skill_file_content_rejects_description_too_long() {
1409        let long_desc = "a".repeat(MAX_SKILL_DESCRIPTION_LEN + 1);
1410        let content = format!(
1411            r#"---
1412name: test
1413description: {long_desc}
1414---
1415
1416Content.
1417"#
1418        );
1419
1420        let result = parse_skill_file_content(&content);
1421        assert!(result.is_err());
1422        let expected = format!("at most {MAX_SKILL_DESCRIPTION_LEN} bytes");
1423        assert!(result.unwrap_err().to_string().contains(&expected));
1424    }
1425
1426    #[test]
1427    fn test_parse_empty_description() {
1428        let content = r#"---
1429name: test
1430description: ""
1431---
1432
1433Content.
1434"#;
1435
1436        let result = parse_skill_frontmatter(
1437            Path::new("/skills/test/SKILL.md"),
1438            content,
1439            SkillSource::Global,
1440        );
1441        assert!(result.is_err());
1442        assert!(result.unwrap_err().to_string().contains("cannot be empty"));
1443    }
1444
1445    #[test]
1446    fn test_parse_file_too_large() {
1447        let large_content = format!(
1448            r#"---
1449name: test
1450description: Test skill
1451---
1452
1453{}"#,
1454            "x".repeat(MAX_SKILL_FILE_SIZE + 1)
1455        );
1456
1457        let result = parse_skill_frontmatter(
1458            Path::new("/skills/test/SKILL.md"),
1459            &large_content,
1460            SkillSource::Global,
1461        );
1462        assert!(result.is_err());
1463        assert!(result.unwrap_err().to_string().contains("exceeds maximum"));
1464    }
1465
1466    #[test]
1467    fn test_parse_empty_body_after_frontmatter() {
1468        let content = r#"---
1469name: minimal-skill
1470description: A skill with no body content
1471---
1472"#;
1473
1474        let result = parse_skill_frontmatter(
1475            Path::new("/skills/minimal/SKILL.md"),
1476            content,
1477            SkillSource::Global,
1478        );
1479
1480        let skill = result.expect("Empty body should be allowed");
1481        assert_eq!(skill.name, "minimal-skill");
1482        assert_eq!(skill.description, "A skill with no body content");
1483    }
1484
1485    #[test]
1486    fn test_parse_whitespace_only_body() {
1487        let content = "---\nname: whitespace-skill\ndescription: Test\n---\n\n   \n\n   \n";
1488
1489        let result = parse_skill_frontmatter(
1490            Path::new("/skills/ws/SKILL.md"),
1491            content,
1492            SkillSource::Global,
1493        );
1494
1495        let skill = result.expect("Whitespace-only body should be allowed");
1496        assert_eq!(skill.name, "whitespace-skill");
1497    }
1498
1499    #[test]
1500    fn test_parse_skill_with_crlf_line_endings() {
1501        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";
1502
1503        let result = parse_skill_frontmatter(
1504            Path::new("/skills/crlf-skill/SKILL.md"),
1505            content,
1506            SkillSource::Global,
1507        );
1508        let skill = result.expect("CRLF document should parse successfully");
1509
1510        assert_eq!(skill.name, "crlf-skill");
1511        assert_eq!(skill.description, "A skill with CRLF line endings");
1512    }
1513
1514    #[test]
1515    fn test_parse_skill_with_mixed_line_endings() {
1516        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";
1517
1518        let result = parse_skill_frontmatter(
1519            Path::new("/skills/mixed-skill/SKILL.md"),
1520            content,
1521            SkillSource::Global,
1522        );
1523        let skill = result.expect("Mixed line endings should parse successfully");
1524
1525        assert_eq!(skill.name, "mixed-skill");
1526        assert_eq!(skill.description, "Frontmatter uses CRLF, body uses LF");
1527    }
1528
1529    #[test]
1530    fn test_parse_rejects_closing_delimiter_with_trailing_chars() {
1531        // The only `---` after the opener has trailing junk on the same line,
1532        // so it isn't a valid closing delimiter and parsing must error.
1533        let content = "---\nname: foo\ndescription: bar\n---trailing-junk\nbody content\n";
1534
1535        let result = parse_skill_frontmatter(
1536            Path::new("/skills/test/SKILL.md"),
1537            content,
1538            SkillSource::Global,
1539        );
1540        assert!(result.is_err());
1541        assert!(
1542            result
1543                .unwrap_err()
1544                .to_string()
1545                .contains("missing closing frontmatter delimiter")
1546        );
1547    }
1548
1549    #[test]
1550    fn test_parse_accepts_only_truly_terminated_closing_delimiter() {
1551        // The first `---trailing` appears inside a quoted YAML string and is
1552        // NOT alone on its line, so it must not be treated as the closer.
1553        // The real closer comes later as `\n---\n`.
1554        let content = "---\nname: skill-name\ndescription: A real description\nsummary: \"---trailing\"\n---\nbody content\n";
1555
1556        let skill = parse_skill_frontmatter(
1557            Path::new("/skills/skill-name/SKILL.md"),
1558            content,
1559            SkillSource::Global,
1560        )
1561        .expect("Should pick the truly-terminated closing delimiter");
1562
1563        assert_eq!(skill.name, "skill-name");
1564        assert_eq!(skill.description, "A real description");
1565    }
1566
1567    #[test]
1568    fn test_parse_accepts_four_dashes_as_invalid_closer() {
1569        // A line of four dashes is NOT a valid closing delimiter; with no
1570        // valid closer following, parsing must error.
1571        let content = "---\nname: foo\ndescription: bar\n----\nbody content\n";
1572
1573        let result = parse_skill_frontmatter(
1574            Path::new("/skills/test/SKILL.md"),
1575            content,
1576            SkillSource::Global,
1577        );
1578        assert!(result.is_err());
1579        assert!(
1580            result
1581                .unwrap_err()
1582                .to_string()
1583                .contains("missing closing frontmatter delimiter")
1584        );
1585    }
1586
1587    #[gpui::test]
1588    async fn test_load_skills_from_empty_directory(cx: &mut TestAppContext) {
1589        let fs = FakeFs::new(cx.executor());
1590        fs.insert_tree("/skills", serde_json::json!({})).await;
1591
1592        let results = load_skills_from_directory(
1593            &(fs as Arc<dyn Fs>),
1594            Path::new("/skills"),
1595            SkillSource::Global,
1596        )
1597        .await;
1598        assert!(results.is_empty());
1599    }
1600
1601    #[gpui::test]
1602    async fn test_load_single_skill(cx: &mut TestAppContext) {
1603        let fs = FakeFs::new(cx.executor());
1604        fs.insert_tree(
1605            "/skills",
1606            serde_json::json!({
1607                "my-skill": {
1608                    "SKILL.md": "---\nname: my-skill\ndescription: Test skill\n---\n\n# Instructions\nDo stuff."
1609                }
1610            }),
1611        )
1612        .await;
1613
1614        let results = load_skills_from_directory(
1615            &(fs as Arc<dyn Fs>),
1616            Path::new("/skills"),
1617            SkillSource::Global,
1618        )
1619        .await;
1620
1621        assert_eq!(results.len(), 1);
1622        let skill = results[0].as_ref().expect("Should load successfully");
1623        assert_eq!(skill.name, "my-skill");
1624        assert_eq!(skill.description, "Test skill");
1625    }
1626
1627    #[gpui::test]
1628    async fn test_load_symlinked_skill_directory(cx: &mut TestAppContext) {
1629        let fs = FakeFs::new(cx.executor());
1630        fs.insert_tree(
1631            "/external/my-skill",
1632            serde_json::json!({
1633                "SKILL.md": "---\nname: my-skill\ndescription: Symlinked skill\n---\n\n# Instructions"
1634            }),
1635        )
1636        .await;
1637        fs.create_dir(Path::new("/skills")).await.unwrap();
1638        fs.create_symlink(
1639            Path::new("/skills/my-skill"),
1640            PathBuf::from("/external/my-skill"),
1641        )
1642        .await
1643        .unwrap();
1644
1645        let results = load_skills_from_directory(
1646            &(fs as Arc<dyn Fs>),
1647            Path::new("/skills"),
1648            SkillSource::Global,
1649        )
1650        .await;
1651
1652        assert_eq!(results.len(), 1);
1653        let skill = results[0].as_ref().expect("Should load successfully");
1654        assert_eq!(skill.name, "my-skill");
1655        assert_eq!(skill.description, "Symlinked skill");
1656        assert_eq!(
1657            skill.skill_file_path,
1658            Path::new("/skills/my-skill/SKILL.md")
1659        );
1660    }
1661
1662    #[gpui::test]
1663    async fn test_load_nested_skills(cx: &mut TestAppContext) {
1664        let fs = FakeFs::new(cx.executor());
1665        fs.insert_tree(
1666            "/skills",
1667            serde_json::json!({
1668                "skill-one": {
1669                    "SKILL.md": "---\nname: skill-one\ndescription: First skill\n---\n\nContent one"
1670                },
1671                "skill-two": {
1672                    "SKILL.md": "---\nname: skill-two\ndescription: Second skill\n---\n\nContent two"
1673                }
1674            }),
1675        )
1676        .await;
1677
1678        let results = load_skills_from_directory(
1679            &(fs as Arc<dyn Fs>),
1680            Path::new("/skills"),
1681            SkillSource::Global,
1682        )
1683        .await;
1684
1685        assert_eq!(results.len(), 2);
1686        let names: Vec<&str> = results
1687            .iter()
1688            .filter_map(|r| r.as_ref().ok())
1689            .map(|s| s.name.as_str())
1690            .collect();
1691        assert!(names.contains(&"skill-one"));
1692        assert!(names.contains(&"skill-two"));
1693    }
1694
1695    #[gpui::test]
1696    async fn test_load_skills_returns_results_sorted_by_path(cx: &mut TestAppContext) {
1697        // `apply_skill_overrides` resolves same-source name collisions
1698        // by keeping the first entry in iteration order. Without a
1699        // stable sort here, the result depends on `fs.read_dir`, which
1700        // is OS/filesystem-dependent. Assert the contract: results
1701        // come back sorted by skill file path regardless of insertion
1702        // order.
1703        let fs = FakeFs::new(cx.executor());
1704        fs.insert_tree(
1705            "/skills",
1706            serde_json::json!({
1707                "charlie": {
1708                    "SKILL.md": "---\nname: charlie\ndescription: C\n---\n\nC"
1709                },
1710                "alpha": {
1711                    "SKILL.md": "---\nname: alpha\ndescription: A\n---\n\nA"
1712                },
1713                "bravo": {
1714                    "SKILL.md": "---\nname: bravo\ndescription: B\n---\n\nB"
1715                },
1716                "delta": {
1717                    "SKILL.md": "No frontmatter, will fail"
1718                },
1719            }),
1720        )
1721        .await;
1722
1723        let results = load_skills_from_directory(
1724            &(fs as Arc<dyn Fs>),
1725            Path::new("/skills"),
1726            SkillSource::Global,
1727        )
1728        .await;
1729
1730        assert_eq!(results.len(), 4);
1731
1732        let paths: Vec<PathBuf> = results
1733            .iter()
1734            .map(|r| match r {
1735                Ok(skill) => skill.skill_file_path.clone(),
1736                Err(error) => error.path.clone(),
1737            })
1738            .collect();
1739
1740        let mut expected = paths.clone();
1741        expected.sort();
1742        assert_eq!(paths, expected);
1743    }
1744
1745    #[gpui::test]
1746    async fn test_load_ignores_non_skill_files(cx: &mut TestAppContext) {
1747        let fs = FakeFs::new(cx.executor());
1748        fs.insert_tree(
1749            "/skills",
1750            serde_json::json!({
1751                "my-skill": {
1752                    "SKILL.md": "---\nname: my-skill\ndescription: Test\n---\n\nContent"
1753                },
1754                "not-a-skill.txt": "This is not a skill",
1755                "some-dir": {
1756                    "other-file.md": "Not a SKILL.md"
1757                }
1758            }),
1759        )
1760        .await;
1761
1762        let results = load_skills_from_directory(
1763            &(fs as Arc<dyn Fs>),
1764            Path::new("/skills"),
1765            SkillSource::Global,
1766        )
1767        .await;
1768
1769        assert_eq!(results.len(), 1);
1770        let skill = results[0].as_ref().expect("Should load successfully");
1771        assert_eq!(skill.name, "my-skill");
1772    }
1773
1774    #[gpui::test]
1775    async fn test_load_returns_errors_for_invalid_skills(cx: &mut TestAppContext) {
1776        let fs = FakeFs::new(cx.executor());
1777        fs.insert_tree(
1778            "/skills",
1779            serde_json::json!({
1780                "valid-skill": {
1781                    "SKILL.md": "---\nname: valid-skill\ndescription: Valid\n---\n\nContent"
1782                },
1783                "invalid-skill": {
1784                    "SKILL.md": "No frontmatter here"
1785                }
1786            }),
1787        )
1788        .await;
1789
1790        let results = load_skills_from_directory(
1791            &(fs as Arc<dyn Fs>),
1792            Path::new("/skills"),
1793            SkillSource::Global,
1794        )
1795        .await;
1796
1797        assert_eq!(results.len(), 2);
1798
1799        let (successes, errors): (Vec<_>, Vec<_>) = results.iter().partition(|r| r.is_ok());
1800
1801        assert_eq!(successes.len(), 1);
1802        assert_eq!(errors.len(), 1);
1803
1804        let error = errors[0].as_ref().unwrap_err();
1805        assert!(error.path.to_string_lossy().contains("invalid-skill"));
1806    }
1807
1808    #[gpui::test]
1809    async fn test_load_from_nonexistent_directory(cx: &mut TestAppContext) {
1810        let fs = FakeFs::new(cx.executor());
1811
1812        let results = load_skills_from_directory(
1813            &(fs as Arc<dyn Fs>),
1814            Path::new("/nonexistent"),
1815            SkillSource::Global,
1816        )
1817        .await;
1818
1819        assert!(results.is_empty());
1820    }
1821
1822    #[test]
1823    fn test_skill_summary_from_skill() {
1824        let skill = Skill {
1825            name: "test-skill".to_string(),
1826            description: "A test description".to_string(),
1827            source: SkillSource::Global,
1828            directory_path: PathBuf::from("/skills/test-skill"),
1829            skill_file_path: PathBuf::from("/skills/test-skill/SKILL.md"),
1830            load_warnings: Vec::new(),
1831            disable_model_invocation: false,
1832            embedded_body: None,
1833        };
1834
1835        let summary = SkillSummary::from(&skill);
1836        assert_eq!(summary.name, "test-skill");
1837        assert_eq!(summary.description, "A test description");
1838        assert_eq!(summary.location, "/skills/test-skill/SKILL.md");
1839    }
1840
1841    #[gpui::test]
1842    async fn test_nested_skill_md_inside_skill_resources_is_not_loaded(cx: &mut TestAppContext) {
1843        // We only look at immediate children of the skills root, so a
1844        // `SKILL.md` nested inside a skill's resources directory cannot
1845        // accidentally be picked up as a separate skill.
1846        let fs = FakeFs::new(cx.executor());
1847        fs.insert_tree(
1848            "/skills",
1849            serde_json::json!({
1850                "outer": {
1851                    "SKILL.md": "---\nname: outer\ndescription: Outer skill\n---\n\nBody",
1852                    "references": {
1853                        "SKILL.md": "---\nname: bogus-inner\ndescription: Should not load\n---\n\nBody"
1854                    },
1855                },
1856            }),
1857        )
1858        .await;
1859
1860        let results = load_skills_from_directory(
1861            &(fs as Arc<dyn Fs>),
1862            Path::new("/skills"),
1863            SkillSource::Global,
1864        )
1865        .await;
1866
1867        let names: Vec<&str> = results
1868            .iter()
1869            .filter_map(|r| r.as_ref().ok())
1870            .map(|s| s.name.as_str())
1871            .collect();
1872        assert_eq!(names, vec!["outer"]);
1873    }
1874
1875    #[gpui::test]
1876    async fn test_load_oversized_skill_file_short_circuits(cx: &mut TestAppContext) {
1877        // A `SKILL.md` whose size exceeds `MAX_SKILL_FILE_SIZE` must be
1878        // rejected via metadata before we read its contents into memory.
1879        // Otherwise a stray multi-GB file dropped into a skill directory
1880        // would OOM the application before `parse_skill`'s size check fires.
1881        let fs = FakeFs::new(cx.executor());
1882        let oversized_body = "x".repeat(MAX_SKILL_FILE_SIZE + 1);
1883        let oversized_content = format!(
1884            "---\nname: huge\ndescription: Too big\n---\n\n{}",
1885            oversized_body
1886        );
1887        fs.insert_tree(
1888            "/skills",
1889            serde_json::json!({
1890                "huge": {
1891                    "SKILL.md": oversized_content,
1892                }
1893            }),
1894        )
1895        .await;
1896
1897        let results = load_skills_from_directory(
1898            &(fs as Arc<dyn Fs>),
1899            Path::new("/skills"),
1900            SkillSource::Global,
1901        )
1902        .await;
1903
1904        assert_eq!(results.len(), 1);
1905        let err = results[0].as_ref().expect_err("Oversized file must error");
1906        assert!(
1907            err.message.contains("exceeds maximum size"),
1908            "unexpected error message: {}",
1909            err.message
1910        );
1911    }
1912
1913    #[gpui::test]
1914    async fn test_load_skill_frontmatter_parses_metadata_without_body(cx: &mut TestAppContext) {
1915        // `load_skill_frontmatter` should read just enough of the file to
1916        // parse the frontmatter and return a `Skill` with name/description/
1917        // disable_model_invocation populated. The body is intentionally not
1918        // surfaced; callers go through `read_skill_body` for that.
1919        let fs = FakeFs::new(cx.executor());
1920        fs.insert_tree(
1921            "/skills",
1922            serde_json::json!({
1923                "my-skill": {
1924                    "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"
1925                }
1926            }),
1927        )
1928        .await;
1929
1930        let skill = load_skill_frontmatter(
1931            fs as Arc<dyn Fs>,
1932            PathBuf::from("/skills/my-skill/SKILL.md"),
1933            SkillSource::Global,
1934        )
1935        .await
1936        .expect("frontmatter should parse");
1937
1938        assert_eq!(skill.name, "my-skill");
1939        assert_eq!(skill.description, "A skill for tests");
1940        assert!(skill.disable_model_invocation);
1941        assert_eq!(
1942            skill.skill_file_path,
1943            PathBuf::from("/skills/my-skill/SKILL.md")
1944        );
1945        assert_eq!(skill.directory_path, PathBuf::from("/skills/my-skill"));
1946    }
1947
1948    #[gpui::test]
1949    async fn test_read_skill_body_returns_trimmed_body(cx: &mut TestAppContext) {
1950        let fs = FakeFs::new(cx.executor());
1951        fs.insert_tree(
1952            "/skills",
1953            serde_json::json!({
1954                "my-skill": {
1955                    "SKILL.md": "---\nname: my-skill\ndescription: Test skill\n---\n\n# Instructions\n\nDo the thing.\n\n"
1956                }
1957            }),
1958        )
1959        .await;
1960
1961        let body = read_skill_body(fs.as_ref(), Path::new("/skills/my-skill/SKILL.md"))
1962            .await
1963            .expect("body should load");
1964
1965        // Trimmed: no leading blank line after the closing `---`, and no
1966        // trailing whitespace.
1967        assert_eq!(body, "# Instructions\n\nDo the thing.");
1968    }
1969
1970    #[gpui::test]
1971    async fn test_read_skill_body_accepts_description_too_long(cx: &mut TestAppContext) {
1972        let fs = FakeFs::new(cx.executor());
1973        let long_desc = "a".repeat(MAX_SKILL_DESCRIPTION_LEN + 1);
1974        fs.insert_tree(
1975            "/skills",
1976            serde_json::json!({
1977                "long-description": {
1978                    "SKILL.md": format!("---\nname: long-description\ndescription: {long_desc}\n---\n\nBody")
1979                }
1980            }),
1981        )
1982        .await;
1983
1984        let body = read_skill_body(fs.as_ref(), Path::new("/skills/long-description/SKILL.md"))
1985            .await
1986            .expect("body should load despite description-length warning");
1987
1988        assert_eq!(body, "Body");
1989    }
1990
1991    #[gpui::test]
1992    async fn test_read_skill_body_for_skill_without_body(cx: &mut TestAppContext) {
1993        let fs = FakeFs::new(cx.executor());
1994        fs.insert_tree(
1995            "/skills",
1996            serde_json::json!({
1997                "empty": {
1998                    "SKILL.md": "---\nname: empty\ndescription: No body\n---\n"
1999                }
2000            }),
2001        )
2002        .await;
2003
2004        let body = read_skill_body(fs.as_ref(), Path::new("/skills/empty/SKILL.md"))
2005            .await
2006            .expect("body should load");
2007
2008        assert!(body.is_empty(), "expected empty body, got: {body:?}");
2009    }
2010
2011    #[test]
2012    fn is_agents_skills_path_simple_positive() {
2013        assert!(is_agents_skills_path(Path::new(
2014            "foo/.agents/skills/my-skill/SKILL.md"
2015        )));
2016    }
2017
2018    #[test]
2019    fn is_agents_skills_path_simple_negative() {
2020        assert!(!is_agents_skills_path(Path::new("foo/bar/baz")));
2021    }
2022
2023    #[test]
2024    fn is_agents_skills_path_double_agents() {
2025        // `foo/.agents/.agents/skills` contains a `.agents/skills` pair at
2026        // depths 2-3. Any-depth matching catches it; this is intentional, so
2027        // a `.agents/skills` directory the user wasn't expecting to be
2028        // touched still prompts for confirmation.
2029        assert!(is_agents_skills_path(Path::new(
2030            "foo/.agents/.agents/skills"
2031        )));
2032    }
2033
2034    #[test]
2035    fn is_agents_skills_path_agents_without_skills() {
2036        assert!(!is_agents_skills_path(Path::new("foo/.agents/other")));
2037    }
2038
2039    #[test]
2040    fn is_agents_skills_path_at_start() {
2041        assert!(is_agents_skills_path(Path::new(".agents/skills")));
2042    }
2043
2044    #[test]
2045    fn is_agents_skills_path_trailing_agents() {
2046        assert!(!is_agents_skills_path(Path::new("foo/.agents")));
2047    }
2048
2049    #[test]
2050    fn is_agents_skills_path_deep_match() {
2051        // Any-depth matching: nested `.agents/skills` directories — e.g.
2052        // inside vendored sources — are flagged too. We prefer the extra
2053        // prompt over silently letting the agent edit something named
2054        // `.agents/skills`.
2055        assert!(is_agents_skills_path(Path::new("a/b/.agents/skills/x.txt")));
2056        assert!(is_agents_skills_path(Path::new(
2057            "some/random/place/.agents/skills/foo"
2058        )));
2059    }
2060
2061    #[test]
2062    fn is_agents_skills_path_absolute() {
2063        // Absolute paths into a project-local `.agents/skills/` are caught
2064        // by the same consecutive-component match.
2065        assert!(is_agents_skills_path(Path::new(
2066            "/Users/foo/project/.agents/skills/my-skill/SKILL.md"
2067        )));
2068        assert!(!is_agents_skills_path(Path::new("/etc/hosts")));
2069    }
2070
2071    #[test]
2072    fn is_agents_skills_path_case_insensitive() {
2073        // Filesystems on macOS/Windows are case-insensitive by default; the
2074        // classifier must agree.
2075        assert!(is_agents_skills_path(Path::new(".AGENTS/skills/foo")));
2076        assert!(is_agents_skills_path(Path::new(".agents/SKILLS/foo")));
2077        assert!(is_agents_skills_path(Path::new(
2078            "project/.AGENTS/SKILLS/foo"
2079        )));
2080    }
2081
2082    #[test]
2083    fn validate_name_accepts_valid_names() {
2084        assert!(validate_name("draft-pr").is_ok());
2085        assert!(validate_name("a").is_ok());
2086        assert!(validate_name("skill1").is_ok());
2087        assert!(validate_name(&"a".repeat(MAX_SKILL_NAME_LEN)).is_ok());
2088    }
2089
2090    #[test]
2091    fn validate_name_rejects_empty() {
2092        assert!(validate_name("").is_err());
2093    }
2094
2095    #[test]
2096    fn validate_name_rejects_uppercase() {
2097        assert!(validate_name("Draft-PR").is_err());
2098    }
2099
2100    #[test]
2101    fn validate_name_rejects_leading_and_trailing_hyphens() {
2102        assert!(validate_name("-draft").is_err());
2103        assert!(validate_name("draft-").is_err());
2104    }
2105
2106    #[test]
2107    fn validate_name_rejects_invalid_chars() {
2108        assert!(validate_name("draft_pr").is_err());
2109        assert!(validate_name("draft pr").is_err());
2110        assert!(validate_name("draft.pr").is_err());
2111    }
2112
2113    #[test]
2114    fn validate_name_rejects_too_long() {
2115        assert!(validate_name(&"a".repeat(MAX_SKILL_NAME_LEN + 1)).is_err());
2116    }
2117
2118    #[test]
2119    fn validate_description_accepts_valid() {
2120        assert!(validate_description("A useful skill").is_ok());
2121    }
2122
2123    #[test]
2124    fn validate_description_rejects_empty_and_whitespace_only() {
2125        assert!(validate_description("").is_err());
2126        assert!(validate_description("   ").is_err());
2127        assert!(validate_description("\t\n ").is_err());
2128    }
2129
2130    #[test]
2131    fn validate_description_rejects_too_long() {
2132        assert!(validate_description(&"a".repeat(MAX_SKILL_DESCRIPTION_LEN + 1)).is_err());
2133    }
2134
2135    #[test]
2136    fn validate_description_length_is_measured_in_bytes() {
2137        // "é" is 2 bytes in UTF-8. A string of MAX/2 + 1 "é" characters has
2138        // only ~MAX/2 + 1 chars but exceeds MAX bytes, so it must be
2139        // rejected by a byte-based validator (and accepted by a char-based
2140        // one). This regression-tests the byte semantics that strict
2141        // validation and load-time warnings both rely on.
2142        let chars = MAX_SKILL_DESCRIPTION_LEN / 2 + 1;
2143        let description = "é".repeat(chars);
2144        assert!(description.chars().count() <= MAX_SKILL_DESCRIPTION_LEN);
2145        assert!(description.len() > MAX_SKILL_DESCRIPTION_LEN);
2146        assert!(validate_description(&description).is_err());
2147    }
2148
2149    #[test]
2150    fn slugify_output_always_passes_validate_name() {
2151        for input in [
2152            "foo",
2153            "Foo Bar",
2154            "rock & roll",
2155            "---weird---",
2156            "a".repeat(200).as_str(),
2157        ] {
2158            if let Some(slug) = slugify_skill_name(input) {
2159                assert!(
2160                    validate_name(&slug).is_ok(),
2161                    "slug {slug:?} from {input:?} failed validate_name"
2162                );
2163            }
2164        }
2165    }
2166
2167    #[test]
2168    fn skill_share_link_round_trips() {
2169        let content =
2170            "---\nname: my-skill\ndescription: Does a thing.\n---\n\n## Steps\n\nDo the thing.\n";
2171        let link = encode_skill_share_link(content);
2172        let data = link
2173            .strip_prefix("zed://skill?data=")
2174            .expect("link should start with the skill share prefix");
2175        // base64url (no-pad) output must not require percent-encoding.
2176        assert!(!data.contains('+') && !data.contains('/') && !data.contains('='));
2177        assert_eq!(decode_skill_share_link(&link).unwrap(), content);
2178    }
2179
2180    #[test]
2181    fn decode_skill_share_link_rejects_non_skill_links() {
2182        assert!(decode_skill_share_link("zed://settings/agent.skills").is_err());
2183        assert!(decode_skill_share_link("zed://skill").is_err());
2184        assert!(decode_skill_share_link("zed://skill?other=1").is_err());
2185        assert!(decode_skill_share_link("zed://skill?data=!!!notbase64").is_err());
2186    }
2187}
2188
Served at tenant.openagents/omega Member data and write actions are omitted.