1use std::collections::BTreeMap;
2use std::ffi::OsStr;
3#[cfg(unix)]
4use std::ffi::{CStr, CString, OsString};
5use std::fs;
6use std::io::{self, Read};
7#[cfg(unix)]
8use std::os::fd::{AsRawFd, FromRawFd, RawFd};
9#[cfg(unix)]
10use std::os::unix::ffi::{OsStrExt, OsStringExt};
11use std::path::{Component, Path, PathBuf};
12use std::process::{Command, Stdio};
13
14use serde::{Deserialize, Serialize};
15
16use crate::config::config_dir;
17
18#[derive(Debug)]
19pub struct ContextError(String);
20
21impl ContextError {
22 fn new(message: impl Into<String>) -> Self {
23 Self(message.into())
24 }
25}
26
27impl std::fmt::Display for ContextError {
28 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
29 formatter.write_str(&self.0)
30 }
31}
32
33impl std::error::Error for ContextError {}
34
35impl From<io::Error> for ContextError {
36 fn from(_error: io::Error) -> Self {
37 Self::new("instruction context discovery error")
38 }
39}
40
41#[derive(Debug, Clone, PartialEq, Eq)]
42pub struct InstructionSource {
43 pub path: PathBuf,
44 pub contents: String,
45}
46
47#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
50pub struct SkillEntry {
51 pub name: String,
52 pub description: String,
53 pub path: PathBuf,
54 #[serde(default)]
55 pub contents: String,
56 #[serde(default = "default_model_invocable")]
57 pub model_invocable: bool,
58}
59
60fn default_model_invocable() -> bool {
61 true
62}
63
64#[derive(Debug, Clone, PartialEq, Eq)]
65pub struct BootContext {
66 pub system_prompt: String,
67 pub instruction_files: Vec<InstructionSource>,
68 pub skills: Vec<SkillEntry>,
69}
70
71#[cfg(test)]
72fn resolve_boot_context(
73 home: &Path,
74 cwd: &Path,
75 configured_prompt: &str,
76) -> Result<BootContext, ContextError> {
77 resolve_boot_context_with_api_key_env(home, cwd, configured_prompt, None)
78}
79
80pub(crate) fn resolve_boot_context_with_api_key_env(
81 home: &Path,
82 cwd: &Path,
83 configured_prompt: &str,
84 api_key_env: Option<&str>,
85) -> Result<BootContext, ContextError> {
86 let cwd = fs::canonicalize(cwd)
87 .map_err(|_error| ContextError::new("unable to resolve working directory"))?;
88 let root = git_root(&cwd, api_key_env);
89 let project_directories = ancestor_directories(&root, &cwd);
90
91 let mut instruction_files = Vec::new();
92 if let Some(instruction) = preferred_instruction(&config_dir(home))? {
93 instruction_files.push(instruction);
94 }
95 for directory in &project_directories {
96 if let Some(instruction) = preferred_instruction(directory)? {
97 instruction_files.push(instruction);
98 }
99 }
100
101 let mut skills = BTreeMap::new();
104 discover_skills(&home.join(".agents").join("skills"), &mut skills)?;
105 for directory in &project_directories {
106 discover_skills(&directory.join(".agents").join("skills"), &mut skills)?;
107 }
108 let skills = skills.into_values().collect::<Vec<_>>();
109 let system_prompt = build_system_prompt(configured_prompt, &instruction_files, &skills);
110
111 Ok(BootContext {
112 system_prompt,
113 instruction_files,
114 skills,
115 })
116}
117
118fn git_root(cwd: &Path, api_key_env: Option<&str>) -> PathBuf {
119 let mut command = Command::new("git");
120 command
121 .arg("-C")
122 .arg(cwd)
123 .args(["rev-parse", "--show-toplevel"]);
124 if let Some(api_key_env) = api_key_env
125 .map(str::trim)
126 .filter(|api_key_env| !api_key_env.is_empty())
127 {
128 command.env_remove(api_key_env);
129 }
130 let output = command
131 .stdout(Stdio::piped())
132 .stderr(Stdio::null())
133 .output();
134
135 match output {
136 Ok(output) if output.status.success() => {
137 let text = String::from_utf8_lossy(&output.stdout).trim().to_owned();
138 if !text.is_empty() {
139 if let Ok(path) = fs::canonicalize(text) {
140 return path;
141 }
142 }
143 cwd.to_owned()
144 }
145 _ => cwd.to_owned(),
146 }
147}
148
149fn ancestor_directories(root: &Path, cwd: &Path) -> Vec<PathBuf> {
150 let mut directories = Vec::new();
151 let mut current = cwd;
152 loop {
153 directories.push(current.to_owned());
154 if current == root {
155 break;
156 }
157 let Some(parent) = current.parent() else {
158 break;
159 };
160 if !cwd.starts_with(parent) || !parent.starts_with(root) {
161 break;
162 }
163 current = parent;
164 }
165 directories.reverse();
166 directories
167}
168
169#[cfg(unix)]
170struct ContextDirectory {
171 file: fs::File,
172}
173
174#[cfg(not(unix))]
175struct ContextDirectory {
176 path: PathBuf,
177}
178
179#[cfg(unix)]
180fn path_component_unavailable(error: &io::Error) -> bool {
181 error.kind() == io::ErrorKind::NotFound
182 || error.raw_os_error() == Some(libc::ENOTDIR)
183 || error.raw_os_error() == Some(libc::ELOOP)
184}
185
186#[cfg(unix)]
187fn open_directory_at(parent: RawFd, name: &OsStr) -> io::Result<Option<fs::File>> {
188 let name = CString::new(name.as_bytes())
189 .map_err(|_error| io::Error::new(io::ErrorKind::InvalidInput, "path contains NUL"))?;
190 let flags = libc::O_RDONLY | libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC;
191 let fd = unsafe { libc::openat(parent, name.as_ptr(), flags, 0) };
192 if fd < 0 {
193 let error = io::Error::last_os_error();
194 if path_component_unavailable(&error) {
195 return Ok(None);
196 }
197 return Err(error);
198 }
199 Ok(Some(unsafe { fs::File::from_raw_fd(fd) }))
200}
201
202#[cfg(unix)]
203fn open_instruction_file_at(parent: RawFd, name: &OsStr) -> io::Result<Option<fs::File>> {
204 let name = CString::new(name.as_bytes())
205 .map_err(|_error| io::Error::new(io::ErrorKind::InvalidInput, "path contains NUL"))?;
206 let flags = libc::O_RDONLY | libc::O_NONBLOCK | libc::O_CLOEXEC;
207 let fd = unsafe { libc::openat(parent, name.as_ptr(), flags, 0) };
208 if fd < 0 {
209 let error = io::Error::last_os_error();
210 if path_component_unavailable(&error) {
211 return Ok(None);
212 }
213 return Err(error);
214 }
215 let file = unsafe { fs::File::from_raw_fd(fd) };
216 if !file.metadata()?.is_file() {
217 return Ok(None);
218 }
219 Ok(Some(file))
220}
221
222#[cfg(unix)]
223fn open_file_at(parent: RawFd, name: &OsStr) -> io::Result<Option<fs::File>> {
224 let name = CString::new(name.as_bytes())
225 .map_err(|_error| io::Error::new(io::ErrorKind::InvalidInput, "path contains NUL"))?;
226 let flags = libc::O_RDONLY | libc::O_NOFOLLOW | libc::O_NONBLOCK | libc::O_CLOEXEC;
227 let fd = unsafe { libc::openat(parent, name.as_ptr(), flags, 0) };
228 if fd < 0 {
229 let error = io::Error::last_os_error();
230 if path_component_unavailable(&error) {
231 return Ok(None);
232 }
233 return Err(error);
234 }
235 let file = unsafe { fs::File::from_raw_fd(fd) };
236 if !file.metadata()?.is_file() {
237 return Ok(None);
238 }
239 Ok(Some(file))
240}
241
242#[cfg(unix)]
243impl ContextDirectory {
244 fn open(path: &Path) -> io::Result<Option<Self>> {
245 let start = if path.is_absolute() {
246 OsStr::new("/")
247 } else {
248 OsStr::new(".")
249 };
250 let Some(file) = open_directory_at(libc::AT_FDCWD, start)? else {
251 return Ok(None);
252 };
253 let mut directory = Self { file };
254
255 for component in path.components() {
256 let name = match component {
257 Component::Prefix(_) => {
258 return Err(io::Error::new(
259 io::ErrorKind::InvalidInput,
260 "path prefix is not supported on Unix",
261 ));
262 }
263 Component::RootDir | Component::CurDir => continue,
264 Component::ParentDir => OsStr::new(".."),
265 Component::Normal(name) => name,
266 };
267 let Some(file) = open_directory_at(directory.file.as_raw_fd(), name)? else {
268 return Ok(None);
269 };
270 directory = Self { file };
271 }
272
273 Ok(Some(directory))
274 }
275
276 fn open_child_directory(&self, name: &OsStr) -> io::Result<Option<Self>> {
277 let Some(file) = open_directory_at(self.file.as_raw_fd(), name)? else {
278 return Ok(None);
279 };
280 Ok(Some(Self { file }))
281 }
282
283 fn open_instruction_file(&self, name: &OsStr) -> io::Result<Option<fs::File>> {
284 open_instruction_file_at(self.file.as_raw_fd(), name)
285 }
286
287 fn open_regular_file(&self, name: &OsStr) -> io::Result<Option<fs::File>> {
288 open_file_at(self.file.as_raw_fd(), name)
289 }
290
291 fn entries(&self) -> io::Result<Vec<OsString>> {
292 read_directory_entries(&self.file)
293 }
294}
295
296#[cfg(not(unix))]
297impl ContextDirectory {
298 fn open(path: &Path) -> io::Result<Option<Self>> {
299 match fs::symlink_metadata(path) {
300 Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_dir() => Ok(None),
301 Ok(_) => Ok(Some(Self {
302 path: path.to_owned(),
303 })),
304 Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None),
305 Err(error) => Err(error),
306 }
307 }
308
309 fn open_child_directory(&self, name: &OsStr) -> io::Result<Option<Self>> {
310 Self::open(&self.path.join(name))
311 }
312
313 fn open_instruction_file(&self, name: &OsStr) -> io::Result<Option<fs::File>> {
314 open_instruction_file(&self.path.join(name))
315 }
316
317 fn open_regular_file(&self, name: &OsStr) -> io::Result<Option<fs::File>> {
318 open_regular_file(&self.path.join(name))
319 }
320
321 fn entries(&self) -> io::Result<Vec<std::ffi::OsString>> {
322 fs::read_dir(&self.path)?
323 .map(|entry| entry.map(|entry| entry.file_name()))
324 .collect()
325 }
326}
327
328#[cfg(unix)]
329struct DirectoryStream(*mut libc::DIR);
330
331#[cfg(unix)]
332impl Drop for DirectoryStream {
333 fn drop(&mut self) {
334 unsafe {
335 libc::closedir(self.0);
336 }
337 }
338}
339
340#[cfg(unix)]
341fn reset_directory_errno() {
342 #[cfg(any(target_os = "linux", target_os = "android"))]
343 unsafe {
344 *libc::__errno_location() = 0;
345 }
346 #[cfg(any(
347 target_os = "macos",
348 target_os = "ios",
349 target_os = "tvos",
350 target_os = "watchos",
351 target_os = "freebsd",
352 target_os = "dragonfly",
353 target_os = "openbsd",
354 target_os = "netbsd"
355 ))]
356 unsafe {
357 *libc::__error() = 0;
358 }
359}
360
361#[cfg(unix)]
362fn directory_errno() -> libc::c_int {
363 #[cfg(any(target_os = "linux", target_os = "android"))]
364 {
365 unsafe { *libc::__errno_location() }
366 }
367 #[cfg(any(
368 target_os = "macos",
369 target_os = "ios",
370 target_os = "tvos",
371 target_os = "watchos",
372 target_os = "freebsd",
373 target_os = "dragonfly",
374 target_os = "openbsd",
375 target_os = "netbsd"
376 ))]
377 {
378 unsafe { *libc::__error() }
379 }
380 #[cfg(not(any(
381 target_os = "linux",
382 target_os = "android",
383 target_os = "macos",
384 target_os = "ios",
385 target_os = "tvos",
386 target_os = "watchos",
387 target_os = "freebsd",
388 target_os = "dragonfly",
389 target_os = "openbsd",
390 target_os = "netbsd"
391 )))]
392 {
393 0
394 }
395}
396
397#[cfg(unix)]
398fn read_directory_entries(file: &fs::File) -> io::Result<Vec<OsString>> {
399 let duplicate = unsafe { libc::dup(file.as_raw_fd()) };
400 if duplicate < 0 {
401 return Err(io::Error::last_os_error());
402 }
403 let directory = unsafe { libc::fdopendir(duplicate) };
404 if directory.is_null() {
405 let error = io::Error::last_os_error();
406 unsafe {
407 libc::close(duplicate);
408 }
409 return Err(error);
410 }
411 let directory = DirectoryStream(directory);
412 let mut entries = Vec::new();
413 loop {
414 reset_directory_errno();
415 let entry = unsafe { libc::readdir(directory.0) };
416 if entry.is_null() {
417 let error_number = directory_errno();
418 if error_number != 0 {
419 return Err(io::Error::from_raw_os_error(error_number));
420 }
421 break;
422 }
423 let name = unsafe { CStr::from_ptr((*entry).d_name.as_ptr()) }.to_bytes();
424 if name != b"." && name != b".." {
425 entries.push(OsString::from_vec(name.to_vec()));
426 }
427 }
428 Ok(entries)
429}
430
431#[cfg(not(unix))]
432fn open_instruction_file(path: &Path) -> io::Result<Option<fs::File>> {
433 let file = match fs::File::open(path) {
434 Ok(file) => file,
435 Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None),
436 Err(error) => return Err(error),
437 };
438 if !file.metadata()?.is_file() {
439 return Ok(None);
440 }
441 Ok(Some(file))
442}
443
444#[cfg(not(unix))]
445fn open_regular_file(path: &Path) -> io::Result<Option<fs::File>> {
446 let mut options = fs::OpenOptions::new();
447 options.read(true);
448 match fs::symlink_metadata(path) {
449 Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_file() => {
450 return Ok(None);
451 }
452 Ok(_) => {}
453 Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None),
454 Err(error) => return Err(error),
455 }
456
457 let file = match options.open(path) {
458 Ok(file) => file,
459 Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None),
460 Err(error) => return Err(error),
461 };
462 if !file.metadata()?.is_file() {
463 return Ok(None);
464 }
465 Ok(Some(file))
466}
467
468fn read_open_file(mut file: fs::File) -> io::Result<String> {
469 let mut contents = String::new();
470 file.read_to_string(&mut contents)?;
471 Ok(contents)
472}
473
474fn preferred_instruction(directory: &Path) -> Result<Option<InstructionSource>, ContextError> {
475 let Some(directory_fd) = ContextDirectory::open(directory)
476 .map_err(|_error| ContextError::new("unable to inspect instruction context"))?
477 else {
478 return Ok(None);
479 };
480
481 for name in [OsStr::new("AGENTS.md"), OsStr::new("CLAUDE.md")] {
482 let Some(file) = directory_fd
483 .open_instruction_file(name)
484 .map_err(|_error| ContextError::new("unable to inspect instruction context"))?
485 else {
486 continue;
487 };
488 let contents = read_open_file(file)
489 .map_err(|_error| ContextError::new("unable to read instruction context"))?;
490 return Ok(Some(InstructionSource {
491 path: directory.join(name),
492 contents,
493 }));
494 }
495 Ok(None)
496}
497
498fn discover_skills(
499 skills_root: &Path,
500 skills: &mut BTreeMap<String, SkillEntry>,
501) -> Result<(), ContextError> {
502 let Some(skills_parent_path) = skills_root.parent() else {
503 return Ok(());
504 };
505 let Some(skills_parent) = ContextDirectory::open(skills_parent_path)
506 .map_err(|_error| ContextError::new("unable to inspect skill context"))?
507 else {
508 return Ok(());
509 };
510 let Some(skills_name) = skills_root.file_name() else {
511 return Ok(());
512 };
513 let Some(skills_directory) = skills_parent
514 .open_child_directory(skills_name)
515 .map_err(|_error| ContextError::new("unable to inspect skill context"))?
516 else {
517 return Ok(());
518 };
519 discover_skill_directory(skills_root, &skills_directory, skills)
520}
521
522fn discover_skill_directory(
523 path: &Path,
524 directory: &ContextDirectory,
525 skills: &mut BTreeMap<String, SkillEntry>,
526) -> Result<(), ContextError> {
527 if let Some(file) = directory
528 .open_regular_file(OsStr::new("SKILL.md"))
529 .map_err(|_error| ContextError::new("unable to inspect skill context"))?
530 {
531 if let Ok(contents) = read_open_file(file) {
532 if let Some((name, description, model_invocable)) = parse_skill_frontmatter(&contents) {
533 skills.insert(
534 name.clone(),
535 SkillEntry {
536 name,
537 description,
538 path: path.join("SKILL.md"),
539 contents,
540 model_invocable,
541 },
542 );
543 }
544 }
545 }
546
547 let mut names = directory
548 .entries()
549 .map_err(|_error| ContextError::new("unable to inspect skill context"))?;
550 names.sort();
551 for name in names {
552 let Some(child) = directory
553 .open_child_directory(&name)
554 .map_err(|_error| ContextError::new("unable to inspect skill context"))?
555 else {
556 continue;
557 };
558 discover_skill_directory(&path.join(&name), &child, skills)?;
559 }
560 Ok(())
561}
562
563fn parse_skill_frontmatter(contents: &str) -> Option<(String, String, bool)> {
564 let lines = contents.lines().collect::<Vec<_>>();
565 if lines.first().map(|line| line.trim()) != Some("---") {
566 return None;
567 }
568 let end = lines
569 .iter()
570 .enumerate()
571 .skip(1)
572 .find(|(_, line)| line.trim() == "---")
573 .map(|(index, _)| index)?;
574
575 let mut name = None;
576 let mut description = None;
577 let mut model_invocable = true;
578 let mut index = 1;
579 while index < end {
580 let line = lines[index];
581 let trimmed = line.trim_start();
582 if let Some(value) = trimmed.strip_prefix("name:") {
583 name = parse_scalar(value);
584 index += 1;
585 continue;
586 }
587 if let Some(value) = trimmed.strip_prefix("disable-model-invocation:") {
588 model_invocable = !matches!(value.trim(), "true" | "True" | "TRUE");
589 index += 1;
590 continue;
591 }
592 if let Some(value) = trimmed.strip_prefix("description:") {
593 let value = value.trim();
594 if matches!(value, "|" | "|-" | "|+" | ">" | ">-" | ">+") {
595 let folded = value.starts_with('>');
596 index += 1;
597 let mut block = Vec::new();
598 while index < end {
599 let block_line = lines[index];
600 if !block_line.trim().is_empty() && !block_line.starts_with(char::is_whitespace)
601 {
602 break;
603 }
604 block.push(block_line.trim().to_owned());
605 index += 1;
606 }
607 description = Some(if folded {
608 block.join(" ").trim().to_owned()
609 } else {
610 block.join("\n").trim().to_owned()
611 });
612 continue;
613 }
614 description = parse_scalar(value);
615 }
616 index += 1;
617 }
618
619 let name = name?.trim().to_owned();
620 let description = description?.trim().to_owned();
621 if !valid_skill_name(&name) || description.is_empty() || description.chars().count() > 1024 {
622 return None;
623 }
624 Some((name, description, model_invocable))
625}
626
627fn valid_skill_name(name: &str) -> bool {
628 !name.is_empty()
629 && name.len() <= 64
630 && !name.starts_with('-')
631 && !name.ends_with('-')
632 && !name.contains("--")
633 && name
634 .bytes()
635 .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-')
636}
637
638fn parse_scalar(value: &str) -> Option<String> {
639 let value = value.trim();
640 if value.is_empty() {
641 return None;
642 }
643 if value.starts_with('"') && value.ends_with('"') && value.len() >= 2 {
644 return serde_json::from_str(value).ok();
645 }
646 if value.starts_with('\'') && value.ends_with('\'') && value.len() >= 2 {
647 return Some(value[1..value.len() - 1].replace("''", "'"));
648 }
649 Some(value.to_owned())
650}
651
652fn escape_xml(text: &str) -> String {
656 text.replace('&', "&")
657 .replace('<', "<")
658 .replace('>', ">")
659 .replace('\"', """)
660 .replace('\'', "'")
661}
662
663fn build_system_prompt(
664 configured_prompt: &str,
665 instruction_files: &[InstructionSource],
666 skills: &[SkillEntry],
667) -> String {
668 let mut sections = vec![configured_prompt.trim_end().to_owned()];
669 for instruction in instruction_files {
670 sections.push(format!(
671 "## Instructions from {}\n{}",
672 instruction.path.display(),
673 instruction.contents.trim_end()
674 ));
675 }
676 let invocable_skills = skills
677 .iter()
678 .filter(|skill| skill.model_invocable)
679 .collect::<Vec<_>>();
680 if !invocable_skills.is_empty() {
681 let mut catalog = String::from("<available_skills>\n");
682 for skill in invocable_skills {
683 catalog.push_str(&format!(
684 "<skill>\n<name>{}</name>\n<description>{}</description>\n<location>{}</location>\n</skill>\n",
685 escape_xml(&skill.name),
686 escape_xml(&skill.description),
687 escape_xml(&skill.path.display().to_string())
688 ));
689 }
690 catalog.push_str("</available_skills>");
691 sections.push(catalog);
692 }
693 sections.join("\n\n")
694}
695
696#[cfg(test)]
697mod tests {
698 use super::*;
699 #[cfg(unix)]
700 use std::os::unix::fs::symlink;
701 use std::sync::atomic::{AtomicU64, Ordering};
702 use std::time::{SystemTime, UNIX_EPOCH};
703
704 static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
705
706 fn temporary_tree() -> (PathBuf, PathBuf) {
707 let home = loop {
708 let stamp = SystemTime::now()
709 .duration_since(UNIX_EPOCH)
710 .expect("clock")
711 .as_nanos();
712 let counter = TEMP_COUNTER.fetch_add(1, Ordering::Relaxed);
713 let path = std::env::temp_dir().join(format!(
714 "lucy-context-{stamp}-{}-{counter}",
715 std::process::id()
716 ));
717 match fs::create_dir(&path) {
718 Ok(()) => break path,
719 Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue,
720 Err(error) => panic!("temp tree: {error}"),
721 }
722 };
723 let home = fs::canonicalize(&home).expect("canonical temp tree");
724 let project = home.join("project").join("nested");
725 fs::create_dir_all(&project).expect("tree");
726 Command::new("git")
727 .arg("-C")
728 .arg(home.join("project"))
729 .args(["init", "-q"])
730 .output()
731 .expect("git init");
732 (home, project)
733 }
734
735 #[test]
736 fn context_uses_precedence_and_specific_skill_override() {
737 let (home, cwd) = temporary_tree();
738 let project = home.join("project");
739 fs::create_dir_all(config_dir(&home)).expect("global dir");
740 fs::write(config_dir(&home).join("CLAUDE.md"), "global claude").expect("global");
741 fs::write(config_dir(&home).join("AGENTS.md"), "global agents").expect("global agents");
742 fs::write(project.join("CLAUDE.md"), "root claude").expect("root claude");
743 fs::write(project.join("AGENTS.md"), "root agents").expect("root agents");
744 fs::write(cwd.join("CLAUDE.md"), "nested claude").expect("nested claude");
745
746 let global_skill = home.join(".agents/skills/shared/SKILL.md");
747 let root_skill = project.join(".agents/skills/shared/SKILL.md");
748 let nested_skill = cwd.join(".agents/skills/nested/SKILL.md");
749 fs::create_dir_all(global_skill.parent().expect("parent")).expect("global skills");
750 fs::create_dir_all(root_skill.parent().expect("parent")).expect("root skills");
751 fs::create_dir_all(nested_skill.parent().expect("parent")).expect("nested skills");
752 fs::write(
753 global_skill,
754 "---\nname: shared\ndescription: global description\n---\n# global",
755 )
756 .expect("global skill");
757 fs::write(
758 root_skill,
759 "---\nname: shared\ndescription: root description\n---\n# root",
760 )
761 .expect("root skill");
762 fs::write(
763 &nested_skill,
764 "---\nname: nested\ndescription: nested description\n---\n# nested",
765 )
766 .expect("nested skill");
767
768 let context = resolve_boot_context(&home, &cwd, "configured").expect("context");
769 assert_eq!(context.instruction_files.len(), 3);
770 assert_eq!(
771 context.instruction_files[0].path,
772 config_dir(&home).join("AGENTS.md")
773 );
774 assert!(context.instruction_files[0]
775 .contents
776 .contains("global agents"));
777 assert!(context.instruction_files[1]
778 .contents
779 .contains("root agents"));
780 assert!(context.instruction_files[2]
781 .contents
782 .contains("nested claude"));
783 assert!(!context.system_prompt.contains("root claude"));
784 assert!(context.system_prompt.contains("root description"));
785 assert!(!context.system_prompt.contains("global description"));
786 assert!(context.system_prompt.contains("nested description"));
787 assert!(context
788 .system_prompt
789 .contains(&nested_skill.display().to_string()));
790 assert!(!context.system_prompt.contains("# nested"));
791
792 fs::remove_dir_all(home).expect("remove tree");
793 }
794
795 #[test]
796 fn context_failure_does_not_echo_a_secret_bearing_path() {
797 let (home, _cwd) = temporary_tree();
798 let missing = home.join("provider-secret-context-missing");
799 let error = resolve_boot_context(&home, &missing, "configured")
800 .expect_err("missing working directory");
801 let message = error.to_string();
802 assert!(message.contains("working directory"));
803 assert!(!message.contains("provider-secret"));
804 assert!(!message.contains(&missing.display().to_string()));
805 fs::remove_dir_all(home).expect("remove tree");
806 }
807
808 #[cfg(unix)]
809 #[test]
810 fn context_follows_symlinked_instruction_files_but_ignores_symlinked_skills() {
811 let (home, cwd) = temporary_tree();
812 let project = home.join("project");
813 fs::create_dir_all(config_dir(&home)).expect("global directory");
814 let global_instruction_target = home.join("global-instructions.md");
815 fs::write(&global_instruction_target, "symlinked global instructions")
816 .expect("global target");
817 symlink(
818 &global_instruction_target,
819 config_dir(&home).join("AGENTS.md"),
820 )
821 .expect("global instruction symlink");
822 fs::write(
823 config_dir(&home).join("CLAUDE.md"),
824 "real global instructions",
825 )
826 .expect("global fallback");
827
828 let project_instruction_target = home.join("project-instructions.md");
829 fs::write(
830 &project_instruction_target,
831 "symlinked project instructions",
832 )
833 .expect("project target");
834 symlink(&project_instruction_target, project.join("AGENTS.md"))
835 .expect("project agents symlink");
836 symlink(&project_instruction_target, project.join("CLAUDE.md"))
837 .expect("project claude symlink");
838
839 let global_skills = home.join(".agents/skills");
840 fs::create_dir_all(&global_skills).expect("global skills");
841 let linked_directory_target = home.join("linked-skill-directory");
842 fs::create_dir(&linked_directory_target).expect("linked directory target");
843 fs::write(
844 linked_directory_target.join("SKILL.md"),
845 "---\nname: linked-directory\ndescription: linked directory\n---\n",
846 )
847 .expect("linked directory skill");
848 symlink(
849 &linked_directory_target,
850 global_skills.join("linked-directory"),
851 )
852 .expect("skill directory symlink");
853
854 let linked_file_target = home.join("linked-skill-file.md");
855 fs::write(
856 &linked_file_target,
857 "---\nname: linked-file\ndescription: linked file\n---\n",
858 )
859 .expect("linked file target");
860 let linked_file_directory = global_skills.join("linked-file");
861 fs::create_dir(&linked_file_directory).expect("linked file directory");
862 symlink(&linked_file_target, linked_file_directory.join("SKILL.md"))
863 .expect("skill file symlink");
864
865 let valid_skill = global_skills.join("valid/SKILL.md");
866 fs::create_dir_all(valid_skill.parent().expect("valid skill parent"))
867 .expect("valid skill directory");
868 fs::write(
869 &valid_skill,
870 "---\nname: valid\ndescription: valid skill\n---\n",
871 )
872 .expect("valid skill");
873
874 let project_skill_target = home.join("project-skills");
875 let project_skill = project_skill_target.join("root-only/SKILL.md");
876 fs::create_dir_all(project_skill.parent().expect("project skill parent"))
877 .expect("project skill target");
878 fs::write(
879 &project_skill,
880 "---\nname: project-only\ndescription: project only\n---\n",
881 )
882 .expect("project skill");
883 fs::create_dir_all(project.join(".agents")).expect("project agents directory");
884 symlink(&project_skill_target, project.join(".agents/skills")).expect("skill root symlink");
885
886 let context = resolve_boot_context(&home, &cwd, "configured").expect("context");
887 assert_eq!(context.instruction_files.len(), 2);
888 assert_eq!(
889 context.instruction_files[0].path,
890 config_dir(&home).join("AGENTS.md")
891 );
892 assert_eq!(
893 context.instruction_files[0].contents,
894 "symlinked global instructions"
895 );
896 assert_eq!(context.instruction_files[1].path, project.join("AGENTS.md"));
897 assert_eq!(
898 context.instruction_files[1].contents,
899 "symlinked project instructions"
900 );
901 assert_eq!(context.skills.len(), 1);
902 assert_eq!(context.skills[0].name, "valid");
903 assert!(context
904 .system_prompt
905 .contains("symlinked global instructions"));
906 assert!(context
907 .system_prompt
908 .contains("symlinked project instructions"));
909 assert!(!context.system_prompt.contains("real global instructions"));
910 assert!(!context.system_prompt.contains("linked-directory"));
911 assert!(!context.system_prompt.contains("linked-file"));
912 assert!(!context.system_prompt.contains("project-only"));
913
914 fs::remove_dir_all(home).expect("remove tree");
915 }
916
917 #[cfg(unix)]
918 #[test]
919 fn context_ignores_symlinked_intermediate_parents() {
920 let (home, cwd) = temporary_tree();
921 let linked_home_target = home.join("linked-home-target");
922 fs::create_dir_all(linked_home_target.join(".config/lucy")).expect("linked Lucy directory");
923 fs::write(
924 linked_home_target.join(".config/lucy/AGENTS.md"),
925 "symlinked intermediate instructions",
926 )
927 .expect("linked instructions");
928 let linked_skill = linked_home_target.join(".agents/skills/linked/SKILL.md");
929 fs::create_dir_all(linked_skill.parent().expect("linked skill parent"))
930 .expect("linked skill directory");
931 fs::write(
932 &linked_skill,
933 "---\nname: linked-intermediate\ndescription: linked intermediate\n---\n",
934 )
935 .expect("linked skill");
936 let linked_home = home.join("linked-home");
937 symlink(&linked_home_target, &linked_home).expect("linked home");
938
939 let context = resolve_boot_context(&linked_home, &cwd, "configured").expect("context");
940 assert!(context.instruction_files.is_empty());
941 assert!(context.skills.is_empty());
942 assert!(!context.system_prompt.contains("symlinked intermediate"));
943 assert!(!context.system_prompt.contains("linked-intermediate"));
944
945 fs::remove_dir_all(home).expect("remove tree");
946 }
947
948 #[test]
949 fn skill_frontmatter_enforces_standard_names_and_hides_explicit_only_skills() {
950 assert!(
951 parse_skill_frontmatter("---\nname: valid-skill-2\ndescription: visible\n---\n")
952 .is_some()
953 );
954 assert!(
955 parse_skill_frontmatter("---\nname: Invalid_Skill\ndescription: invalid\n---\n")
956 .is_none()
957 );
958 let hidden = SkillEntry {
959 name: "private-skill".to_owned(),
960 description: "hidden from automatic selection".to_owned(),
961 path: PathBuf::from("/skills/private/SKILL.md"),
962 contents: "instructions".to_owned(),
963 model_invocable: false,
964 };
965 let prompt = build_system_prompt("configured", &[], &[hidden]);
966 assert!(!prompt.contains("private-skill"));
967 assert_eq!(escape_xml("a<&>\"'"), "a<&>"'");
968 }
969
970 #[test]
971 fn invalid_skill_metadata_is_skipped() {
972 let (home, cwd) = temporary_tree();
973 let invalid = cwd.join(".agents/skills/invalid/SKILL.md");
974 fs::create_dir_all(invalid.parent().expect("parent")).expect("skill dir");
975 fs::write(invalid, "---\nname: invalid\n---\nbody").expect("skill");
976 let context = resolve_boot_context(&home, &cwd, "configured").expect("context");
977 assert!(context.skills.is_empty());
978 assert!(!context.system_prompt.contains("invalid"));
979 fs::remove_dir_all(home).expect("remove tree");
980 }
981}