Skip to repository content2188 lines · 76.3 KB · rust
tenant.openagents/omega
No repository description is available.
OpenAgents Git authority 2026-07-28T02:01:10.284Z Public web read
NIP-34 coordinate
30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omegaMaintainersHidden in public view
References2 branches · 1 tag
Read-only clone
git clone https://openagents.com/git/tenant.openagents/omega.gitBrowse files
agent_skills.rs
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