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