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