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