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