Skip to main content

lucy/
context.rs

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}