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