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#[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 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
500fn escape_xml(text: &str) -> String {
504 text.replace('&', "&")
505 .replace('<', "<")
506 .replace('>', ">")
507 .replace('\"', """)
508 .replace('\'', "'")
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<&>"'");
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}