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