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
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/// A discovered Agent Skill. `contents` is retained so explicit invocations
48/// use the exact, symlink-safe snapshot discovered when the session started.
49#[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    // More-specific project locations override an earlier skill with the
102    // same declared name.
103    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
652/// Keep metadata in the XML-shaped progressive-disclosure catalog from
653/// changing its structure. Full skill contents are intentionally not escaped:
654/// they are loaded only when a skill is selected as instructions.
655fn escape_xml(text: &str) -> String {
656    text.replace('&', "&amp;")
657        .replace('<', "&lt;")
658        .replace('>', "&gt;")
659        .replace('\"', "&quot;")
660        .replace('\'', "&apos;")
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&lt;&amp;&gt;&quot;&apos;");
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}