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