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