Skip to main content

lucy/
context.rs

1use std::collections::{BTreeMap, BTreeSet};
2#[cfg(unix)]
3use std::ffi::CString;
4use std::ffi::OsStr;
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;
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)]
215impl ContextDirectory {
216    fn open(path: &Path) -> io::Result<Option<Self>> {
217        let start = if path.is_absolute() {
218            OsStr::new("/")
219        } else {
220            OsStr::new(".")
221        };
222        let Some(file) = open_directory_at(libc::AT_FDCWD, start)? else {
223            return Ok(None);
224        };
225        let mut directory = Self { file };
226
227        for component in path.components() {
228            let name = match component {
229                Component::Prefix(_) => {
230                    return Err(io::Error::new(
231                        io::ErrorKind::InvalidInput,
232                        "path prefix is not supported on Unix",
233                    ));
234                }
235                Component::RootDir | Component::CurDir => continue,
236                Component::ParentDir => OsStr::new(".."),
237                Component::Normal(name) => name,
238            };
239            let Some(file) = open_directory_at(directory.file.as_raw_fd(), name)? else {
240                return Ok(None);
241            };
242            directory = Self { file };
243        }
244
245        Ok(Some(directory))
246    }
247
248    fn open_instruction_file(&self, name: &OsStr) -> io::Result<Option<fs::File>> {
249        open_instruction_file_at(self.file.as_raw_fd(), name)
250    }
251}
252
253#[cfg(not(unix))]
254impl ContextDirectory {
255    fn open(path: &Path) -> io::Result<Option<Self>> {
256        match fs::symlink_metadata(path) {
257            Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_dir() => Ok(None),
258            Ok(_) => Ok(Some(Self {
259                path: path.to_owned(),
260            })),
261            Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None),
262            Err(error) => Err(error),
263        }
264    }
265
266    fn open_instruction_file(&self, name: &OsStr) -> io::Result<Option<fs::File>> {
267        open_instruction_file(&self.path.join(name))
268    }
269}
270
271#[cfg(not(unix))]
272fn open_instruction_file(path: &Path) -> io::Result<Option<fs::File>> {
273    let file = match fs::File::open(path) {
274        Ok(file) => file,
275        Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None),
276        Err(error) => return Err(error),
277    };
278    if !file.metadata()?.is_file() {
279        return Ok(None);
280    }
281    Ok(Some(file))
282}
283
284fn read_open_file(mut file: fs::File) -> io::Result<String> {
285    let mut contents = String::new();
286    file.read_to_string(&mut contents)?;
287    Ok(contents)
288}
289
290fn preferred_instruction(directory: &Path) -> Result<Option<InstructionSource>, ContextError> {
291    let Some(directory_fd) = ContextDirectory::open(directory)
292        .map_err(|_error| ContextError::new("unable to inspect instruction context"))?
293    else {
294        return Ok(None);
295    };
296
297    for name in [OsStr::new("AGENTS.md"), OsStr::new("CLAUDE.md")] {
298        let Some(file) = directory_fd
299            .open_instruction_file(name)
300            .map_err(|_error| ContextError::new("unable to inspect instruction context"))?
301        else {
302            continue;
303        };
304        let contents = read_open_file(file)
305            .map_err(|_error| ContextError::new("unable to read instruction context"))?;
306        return Ok(Some(InstructionSource {
307            path: directory.join(name),
308            contents,
309        }));
310    }
311    Ok(None)
312}
313
314const README_CHAR_LIMIT: usize = 1000;
315
316fn truncate_readme(contents: &str) -> String {
317    let trimmed = contents.trim_end();
318    if trimmed.chars().count() <= README_CHAR_LIMIT {
319        return trimmed.to_owned();
320    }
321    let truncated: String = trimmed.chars().take(README_CHAR_LIMIT).collect();
322    format!("{truncated}\n\n[README truncated; showing first 1000 characters]")
323}
324
325fn readme_for_directory(directory: &Path) -> Result<Option<InstructionSource>, ContextError> {
326    let Some(directory_fd) = ContextDirectory::open(directory)
327        .map_err(|_error| ContextError::new("unable to inspect instruction context"))?
328    else {
329        return Ok(None);
330    };
331    let Some(file) = directory_fd
332        .open_instruction_file(OsStr::new("README.md"))
333        .map_err(|_error| ContextError::new("unable to inspect instruction context"))?
334    else {
335        return Ok(None);
336    };
337    let contents = read_open_file(file)
338        .map_err(|_error| ContextError::new("unable to read instruction context"))?;
339    Ok(Some(InstructionSource {
340        path: directory.join("README.md"),
341        contents: truncate_readme(&contents),
342    }))
343}
344
345fn discover_skills(
346    skills_root: &Path,
347    skills: &mut BTreeMap<String, SkillEntry>,
348) -> Result<(), ContextError> {
349    let Some(skills_parent) = skills_root.parent() else {
350        return Ok(());
351    };
352    if ContextDirectory::open(skills_parent)
353        .map_err(|_error| ContextError::new("unable to inspect skill context"))?
354        .is_none()
355    {
356        return Ok(());
357    }
358    let mut visited = BTreeSet::new();
359    discover_skill_directory(skills_root, skills, &mut visited)
360}
361
362fn discover_skill_directory(
363    path: &Path,
364    skills: &mut BTreeMap<String, SkillEntry>,
365    visited: &mut BTreeSet<PathBuf>,
366) -> Result<(), ContextError> {
367    let resolved = match fs::canonicalize(path) {
368        Ok(resolved) => resolved,
369        Err(error) if path_component_unavailable(&error) => return Ok(()),
370        Err(_error) => return Err(ContextError::new("unable to inspect skill context")),
371    };
372    let metadata = fs::metadata(&resolved)
373        .map_err(|_error| ContextError::new("unable to inspect skill context"))?;
374    if !metadata.is_dir() || !visited.insert(resolved.clone()) {
375        return Ok(());
376    }
377
378    let skill_path = path.join("SKILL.md");
379    if let Ok(skill_target) = fs::canonicalize(&skill_path) {
380        if fs::metadata(&skill_target).is_ok_and(|metadata| metadata.is_file()) {
381            if let Ok(contents) = fs::read_to_string(&skill_target) {
382                if let Some((name, description, model_invocable)) =
383                    parse_skill_frontmatter(&contents)
384                {
385                    skills.insert(
386                        name.clone(),
387                        SkillEntry {
388                            name,
389                            description,
390                            path: skill_path,
391                            contents,
392                            model_invocable,
393                        },
394                    );
395                }
396            }
397        }
398    }
399
400    let mut entries = fs::read_dir(&resolved)
401        .map_err(|_error| ContextError::new("unable to inspect skill context"))?
402        .collect::<Result<Vec<_>, _>>()
403        .map_err(|_error| ContextError::new("unable to inspect skill context"))?;
404    entries.sort_by_key(|entry| entry.file_name());
405    for entry in entries {
406        discover_skill_directory(&path.join(entry.file_name()), skills, visited)?;
407    }
408    Ok(())
409}
410
411fn parse_skill_frontmatter(contents: &str) -> Option<(String, String, bool)> {
412    let lines = contents.lines().collect::<Vec<_>>();
413    if lines.first().map(|line| line.trim()) != Some("---") {
414        return None;
415    }
416    let end = lines
417        .iter()
418        .enumerate()
419        .skip(1)
420        .find(|(_, line)| line.trim() == "---")
421        .map(|(index, _)| index)?;
422
423    let mut name = None;
424    let mut description = None;
425    let mut model_invocable = true;
426    let mut index = 1;
427    while index < end {
428        let line = lines[index];
429        let trimmed = line.trim_start();
430        if let Some(value) = trimmed.strip_prefix("name:") {
431            name = parse_scalar(value);
432            index += 1;
433            continue;
434        }
435        if let Some(value) = trimmed.strip_prefix("disable-model-invocation:") {
436            model_invocable = !matches!(value.trim(), "true" | "True" | "TRUE");
437            index += 1;
438            continue;
439        }
440        if let Some(value) = trimmed.strip_prefix("description:") {
441            let value = value.trim();
442            if matches!(value, "|" | "|-" | "|+" | ">" | ">-" | ">+") {
443                let folded = value.starts_with('>');
444                index += 1;
445                let mut block = Vec::new();
446                while index < end {
447                    let block_line = lines[index];
448                    if !block_line.trim().is_empty() && !block_line.starts_with(char::is_whitespace)
449                    {
450                        break;
451                    }
452                    block.push(block_line.trim().to_owned());
453                    index += 1;
454                }
455                description = Some(if folded {
456                    block.join(" ").trim().to_owned()
457                } else {
458                    block.join("\n").trim().to_owned()
459                });
460                continue;
461            }
462            description = parse_scalar(value);
463        }
464        index += 1;
465    }
466
467    let name = name?.trim().to_owned();
468    let description = description?.trim().to_owned();
469    if !valid_skill_name(&name) || description.is_empty() || description.chars().count() > 1024 {
470        return None;
471    }
472    Some((name, description, model_invocable))
473}
474
475fn valid_skill_name(name: &str) -> bool {
476    !name.is_empty()
477        && name.len() <= 64
478        && !name.starts_with('-')
479        && !name.ends_with('-')
480        && !name.contains("--")
481        && name
482            .bytes()
483            .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-')
484}
485
486fn parse_scalar(value: &str) -> Option<String> {
487    let value = value.trim();
488    if value.is_empty() {
489        return None;
490    }
491    if value.starts_with('"') && value.ends_with('"') && value.len() >= 2 {
492        return serde_json::from_str(value).ok();
493    }
494    if value.starts_with('\'') && value.ends_with('\'') && value.len() >= 2 {
495        return Some(value[1..value.len() - 1].replace("''", "'"));
496    }
497    Some(value.to_owned())
498}
499
500/// Keep metadata in the XML-shaped progressive-disclosure catalog from
501/// changing its structure. Full skill contents are intentionally not escaped:
502/// they are loaded only when a skill is selected as instructions.
503fn escape_xml(text: &str) -> String {
504    text.replace('&', "&amp;")
505        .replace('<', "&lt;")
506        .replace('>', "&gt;")
507        .replace('\"', "&quot;")
508        .replace('\'', "&apos;")
509}
510
511fn build_system_prompt(
512    cwd: &Path,
513    instruction_files: &[InstructionSource],
514    readme_files: &[InstructionSource],
515    skills: &[SkillEntry],
516) -> String {
517    let mut sections = vec![BUILT_IN_SYSTEM_PROMPT.to_owned()];
518    sections.push(format!("## Working directory\n{}", cwd.display()));
519    for instruction in instruction_files {
520        sections.push(format!(
521            "## Instructions from {}\n{}",
522            instruction.path.display(),
523            instruction.contents.trim_end()
524        ));
525    }
526    for readme in readme_files {
527        sections.push(format!(
528            "## README from {}\n{}",
529            readme.path.display(),
530            readme.contents.trim_end()
531        ));
532    }
533    let invocable_skills = skills
534        .iter()
535        .filter(|skill| skill.model_invocable)
536        .collect::<Vec<_>>();
537    if !invocable_skills.is_empty() {
538        let mut catalog = String::from("<available_skills>\n");
539        for skill in invocable_skills {
540            catalog.push_str(&format!(
541                "<skill>\n<name>{}</name>\n<description>{}</description>\n<location>{}</location>\n</skill>\n",
542                escape_xml(&skill.name),
543                escape_xml(&skill.description),
544                escape_xml(&skill.path.display().to_string())
545            ));
546        }
547        catalog.push_str("</available_skills>");
548        sections.push(catalog);
549    }
550    sections.join("\n\n")
551}
552
553#[cfg(test)]
554mod tests {
555    use super::*;
556    #[cfg(unix)]
557    use std::os::unix::fs::symlink;
558    use std::sync::atomic::{AtomicU64, Ordering};
559    use std::time::{SystemTime, UNIX_EPOCH};
560
561    static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
562
563    fn temporary_tree() -> (PathBuf, PathBuf) {
564        let home = loop {
565            let stamp = SystemTime::now()
566                .duration_since(UNIX_EPOCH)
567                .expect("clock")
568                .as_nanos();
569            let counter = TEMP_COUNTER.fetch_add(1, Ordering::Relaxed);
570            let path = std::env::temp_dir().join(format!(
571                "lucy-context-{stamp}-{}-{counter}",
572                std::process::id()
573            ));
574            match fs::create_dir(&path) {
575                Ok(()) => break path,
576                Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue,
577                Err(error) => panic!("temp tree: {error}"),
578            }
579        };
580        let home = fs::canonicalize(&home).expect("canonical temp tree");
581        let project = home.join("project").join("nested");
582        fs::create_dir_all(&project).expect("tree");
583        Command::new("git")
584            .arg("-C")
585            .arg(home.join("project"))
586            .args(["init", "-q"])
587            .output()
588            .expect("git init");
589        (home, project)
590    }
591
592    #[test]
593    fn context_uses_precedence_and_specific_skill_override() {
594        let (home, cwd) = temporary_tree();
595        let project = home.join("project");
596        fs::create_dir_all(config_dir(&home)).expect("global dir");
597        fs::write(config_dir(&home).join("CLAUDE.md"), "global claude").expect("global");
598        fs::write(config_dir(&home).join("AGENTS.md"), "global agents").expect("global agents");
599        fs::write(project.join("CLAUDE.md"), "root claude").expect("root claude");
600        fs::write(project.join("AGENTS.md"), "root agents").expect("root agents");
601        fs::write(cwd.join("CLAUDE.md"), "nested claude").expect("nested claude");
602
603        let global_skill = home.join(".agents/skills/shared/SKILL.md");
604        let root_skill = project.join(".agents/skills/shared/SKILL.md");
605        let nested_skill = cwd.join(".agents/skills/nested/SKILL.md");
606        fs::create_dir_all(global_skill.parent().expect("parent")).expect("global skills");
607        fs::create_dir_all(root_skill.parent().expect("parent")).expect("root skills");
608        fs::create_dir_all(nested_skill.parent().expect("parent")).expect("nested skills");
609        fs::write(
610            global_skill,
611            "---\nname: shared\ndescription: global description\n---\n# global",
612        )
613        .expect("global skill");
614        fs::write(
615            root_skill,
616            "---\nname: shared\ndescription: root description\n---\n# root",
617        )
618        .expect("root skill");
619        fs::write(
620            &nested_skill,
621            "---\nname: nested\ndescription: nested description\n---\n# nested",
622        )
623        .expect("nested skill");
624
625        let context = resolve_boot_context(&home, &cwd).expect("context");
626        assert_eq!(context.instruction_files.len(), 3);
627        assert_eq!(
628            context.instruction_files[0].path,
629            config_dir(&home).join("AGENTS.md")
630        );
631        assert!(context.instruction_files[0]
632            .contents
633            .contains("global agents"));
634        assert!(context.instruction_files[1]
635            .contents
636            .contains("root agents"));
637        assert!(context.instruction_files[2]
638            .contents
639            .contains("nested claude"));
640        assert!(!context.system_prompt.contains("root claude"));
641        assert!(context.system_prompt.contains("root description"));
642        assert!(!context.system_prompt.contains("global description"));
643        assert!(context.system_prompt.contains("nested description"));
644        assert!(context
645            .system_prompt
646            .contains(&nested_skill.display().to_string()));
647        assert!(!context.system_prompt.contains("# nested"));
648        assert!(context.system_prompt.contains("## Working directory"));
649        assert!(context
650            .system_prompt
651            .contains(&context.cwd.display().to_string()));
652
653        fs::remove_dir_all(home).expect("remove tree");
654    }
655
656    #[test]
657    fn context_failure_does_not_echo_a_secret_bearing_path() {
658        let (home, _cwd) = temporary_tree();
659        let missing = home.join("provider-secret-context-missing");
660        let error = resolve_boot_context(&home, &missing).expect_err("missing working directory");
661        let message = error.to_string();
662        assert!(message.contains("working directory"));
663        assert!(!message.contains("provider-secret"));
664        assert!(!message.contains(&missing.display().to_string()));
665        fs::remove_dir_all(home).expect("remove tree");
666    }
667
668    #[cfg(unix)]
669    #[test]
670    fn context_follows_symlinked_skill_directories_and_files() {
671        let (home, cwd) = temporary_tree();
672        let project = home.join("project");
673        fs::create_dir_all(config_dir(&home)).expect("global directory");
674        let global_instruction_target = home.join("global-instructions.md");
675        fs::write(&global_instruction_target, "symlinked global instructions")
676            .expect("global target");
677        symlink(
678            &global_instruction_target,
679            config_dir(&home).join("AGENTS.md"),
680        )
681        .expect("global instruction symlink");
682        fs::write(
683            config_dir(&home).join("CLAUDE.md"),
684            "real global instructions",
685        )
686        .expect("global fallback");
687
688        let project_instruction_target = home.join("project-instructions.md");
689        fs::write(
690            &project_instruction_target,
691            "symlinked project instructions",
692        )
693        .expect("project target");
694        symlink(&project_instruction_target, project.join("AGENTS.md"))
695            .expect("project agents symlink");
696        symlink(&project_instruction_target, project.join("CLAUDE.md"))
697            .expect("project claude symlink");
698
699        let global_skills = home.join(".agents/skills");
700        fs::create_dir_all(&global_skills).expect("global skills");
701        let linked_directory_target = home.join("linked-skill-directory");
702        fs::create_dir(&linked_directory_target).expect("linked directory target");
703        fs::write(
704            linked_directory_target.join("SKILL.md"),
705            "---\nname: linked-directory\ndescription: linked directory\n---\n",
706        )
707        .expect("linked directory skill");
708        symlink(
709            &linked_directory_target,
710            linked_directory_target.join("loop"),
711        )
712        .expect("skill directory cycle");
713        symlink(
714            &linked_directory_target,
715            global_skills.join("linked-directory"),
716        )
717        .expect("skill directory symlink");
718
719        let linked_file_target = home.join("linked-skill-file.md");
720        fs::write(
721            &linked_file_target,
722            "---\nname: linked-file\ndescription: linked file\n---\n",
723        )
724        .expect("linked file target");
725        let linked_file_directory = global_skills.join("linked-file");
726        fs::create_dir(&linked_file_directory).expect("linked file directory");
727        symlink(&linked_file_target, linked_file_directory.join("SKILL.md"))
728            .expect("skill file symlink");
729
730        let valid_skill = global_skills.join("valid/SKILL.md");
731        fs::create_dir_all(valid_skill.parent().expect("valid skill parent"))
732            .expect("valid skill directory");
733        fs::write(
734            &valid_skill,
735            "---\nname: valid\ndescription: valid skill\n---\n",
736        )
737        .expect("valid skill");
738
739        let project_skill_target = home.join("project-skills");
740        let project_skill = project_skill_target.join("root-only/SKILL.md");
741        fs::create_dir_all(project_skill.parent().expect("project skill parent"))
742            .expect("project skill target");
743        fs::write(
744            &project_skill,
745            "---\nname: project-only\ndescription: project only\n---\n",
746        )
747        .expect("project skill");
748        fs::create_dir_all(project.join(".agents")).expect("project agents directory");
749        symlink(&project_skill_target, project.join(".agents/skills")).expect("skill root symlink");
750
751        let context = resolve_boot_context(&home, &cwd).expect("context");
752        assert_eq!(context.instruction_files.len(), 2);
753        assert_eq!(
754            context.instruction_files[0].path,
755            config_dir(&home).join("AGENTS.md")
756        );
757        assert_eq!(
758            context.instruction_files[0].contents,
759            "symlinked global instructions"
760        );
761        assert_eq!(context.instruction_files[1].path, project.join("AGENTS.md"));
762        assert_eq!(
763            context.instruction_files[1].contents,
764            "symlinked project instructions"
765        );
766        assert_eq!(
767            context
768                .skills
769                .iter()
770                .map(|skill| skill.name.as_str())
771                .collect::<Vec<_>>(),
772            vec!["linked-directory", "linked-file", "project-only", "valid"]
773        );
774        assert_eq!(
775            context
776                .skills
777                .iter()
778                .find(|skill| skill.name == "linked-directory")
779                .expect("linked directory")
780                .path,
781            global_skills.join("linked-directory/SKILL.md")
782        );
783        assert!(context
784            .skills
785            .iter()
786            .find(|skill| skill.name == "linked-file")
787            .expect("linked file")
788            .contents
789            .contains("description: linked file"));
790        assert!(context
791            .system_prompt
792            .contains("symlinked global instructions"));
793        assert!(context
794            .system_prompt
795            .contains("symlinked project instructions"));
796        assert!(!context.system_prompt.contains("real global instructions"));
797        assert!(context.system_prompt.contains("linked-directory"));
798        assert!(context.system_prompt.contains("linked-file"));
799        assert!(context.system_prompt.contains("project-only"));
800
801        fs::remove_dir_all(home).expect("remove tree");
802    }
803
804    #[cfg(unix)]
805    #[test]
806    fn context_ignores_symlinked_intermediate_parents() {
807        let (home, cwd) = temporary_tree();
808        let linked_home_target = home.join("linked-home-target");
809        fs::create_dir_all(linked_home_target.join(".config/lucy")).expect("linked Lucy directory");
810        fs::write(
811            linked_home_target.join(".config/lucy/AGENTS.md"),
812            "symlinked intermediate instructions",
813        )
814        .expect("linked instructions");
815        let linked_skill = linked_home_target.join(".agents/skills/linked/SKILL.md");
816        fs::create_dir_all(linked_skill.parent().expect("linked skill parent"))
817            .expect("linked skill directory");
818        fs::write(
819            &linked_skill,
820            "---\nname: linked-intermediate\ndescription: linked intermediate\n---\n",
821        )
822        .expect("linked skill");
823        let linked_home = home.join("linked-home");
824        symlink(&linked_home_target, &linked_home).expect("linked home");
825
826        let context = resolve_boot_context(&linked_home, &cwd).expect("context");
827        assert!(context.instruction_files.is_empty());
828        assert!(context.skills.is_empty());
829        assert!(!context.system_prompt.contains("symlinked intermediate"));
830        assert!(!context.system_prompt.contains("linked-intermediate"));
831
832        fs::remove_dir_all(home).expect("remove tree");
833    }
834
835    #[test]
836    fn skill_frontmatter_enforces_standard_names_and_hides_explicit_only_skills() {
837        assert!(
838            parse_skill_frontmatter("---\nname: valid-skill-2\ndescription: visible\n---\n")
839                .is_some()
840        );
841        assert!(
842            parse_skill_frontmatter("---\nname: Invalid_Skill\ndescription: invalid\n---\n")
843                .is_none()
844        );
845        let hidden = SkillEntry {
846            name: "private-skill".to_owned(),
847            description: "hidden from automatic selection".to_owned(),
848            path: PathBuf::from("/skills/private/SKILL.md"),
849            contents: "instructions".to_owned(),
850            model_invocable: false,
851        };
852        let prompt = build_system_prompt(Path::new("/"), &[], &[], &[hidden]);
853        assert!(!prompt.contains("private-skill"));
854        assert_eq!(escape_xml("a<&>\"'"), "a&lt;&amp;&gt;&quot;&apos;");
855    }
856
857    #[test]
858    fn invalid_skill_metadata_is_skipped() {
859        let (home, cwd) = temporary_tree();
860        let invalid = cwd.join(".agents/skills/invalid/SKILL.md");
861        fs::create_dir_all(invalid.parent().expect("parent")).expect("skill dir");
862        fs::write(invalid, "---\nname: invalid\n---\nbody").expect("skill");
863        let context = resolve_boot_context(&home, &cwd).expect("context");
864        assert!(context.skills.is_empty());
865        assert!(!context.system_prompt.contains("invalid"));
866        fs::remove_dir_all(home).expect("remove tree");
867    }
868
869    #[test]
870    fn system_prompt_includes_cwd() {
871        let (home, cwd) = temporary_tree();
872        let context = resolve_boot_context(&home, &cwd).expect("context");
873        assert!(context.system_prompt.contains("## Working directory"));
874        assert!(context
875            .system_prompt
876            .contains(&context.cwd.display().to_string()));
877        fs::remove_dir_all(home).expect("remove tree");
878    }
879
880    #[test]
881    fn readme_full_content_in_system_prompt() {
882        let (home, cwd) = temporary_tree();
883        fs::write(cwd.join("README.md"), "# Project\n\nShort readme.").expect("readme");
884        let context = resolve_boot_context(&home, &cwd).expect("context");
885        assert!(context.system_prompt.contains("## README from"));
886        assert!(context.system_prompt.contains("# Project"));
887        assert!(context.system_prompt.contains("Short readme."));
888        assert!(!context.system_prompt.contains("[README truncated"));
889        fs::remove_dir_all(home).expect("remove tree");
890    }
891
892    #[test]
893    fn readme_truncated_when_too_long() {
894        let (home, cwd) = temporary_tree();
895        let content = "a".repeat(1000) + "b";
896        fs::write(cwd.join("README.md"), &content).expect("readme");
897        let context = resolve_boot_context(&home, &cwd).expect("context");
898        assert!(context
899            .system_prompt
900            .contains("[README truncated; showing first 1000 characters]"));
901        fs::remove_dir_all(home).expect("remove tree");
902    }
903
904    #[test]
905    fn readme_from_multiple_ancestor_directories() {
906        let (home, cwd) = temporary_tree();
907        let project = home.join("project");
908        fs::write(project.join("README.md"), "root readme").expect("root readme");
909        fs::write(cwd.join("README.md"), "nested readme").expect("nested readme");
910        let context = resolve_boot_context(&home, &cwd).expect("context");
911        assert!(context.system_prompt.contains("root readme"));
912        assert!(context.system_prompt.contains("nested readme"));
913        fs::remove_dir_all(home).expect("remove tree");
914    }
915
916    #[test]
917    fn no_readme_works_without_error() {
918        let (home, cwd) = temporary_tree();
919        let context = resolve_boot_context(&home, &cwd).expect("context");
920        assert!(!context.system_prompt.contains("## README from"));
921        fs::remove_dir_all(home).expect("remove tree");
922    }
923}