1pub mod diagnostics;
11pub mod discovery;
12pub mod frontmatter;
13pub mod prompts;
14pub mod skills;
15pub mod slash;
16pub mod source_info;
17pub mod themes;
18
19pub use diagnostics::{DiagnosticType, ResourceCollision, ResourceDiagnostic, ResourceType};
20pub use discovery::{
21 PackagePathResolver, PackageResolveError, ParsedSource, PathMetadata, ResolvedPaths,
22 ResolvedResource, ResourceKind, SkillDiscoveryMode, apply_patterns,
23 collect_ancestor_agents_skill_dirs, collect_auto_extension_entries,
24 collect_auto_prompt_entries, collect_auto_theme_entries, collect_skill_entries,
25 find_git_repo_root, is_enabled_by_overrides, package_identity, parse_package_source,
26 path_to_string, resource_precedence_rank, temporary_dir_hash,
27};
28pub use frontmatter::{
29 FrontmatterError, ParsedFrontmatter, extract_frontmatter, frontmatter_bool, frontmatter_string,
30 normalize_newlines, parse_frontmatter, parse_yaml_value, strip_frontmatter,
31};
32pub use prompts::{
33 LoadPromptTemplatesOptions, PromptTemplate, expand_prompt_template, load_prompt_templates,
34 parse_command_args, substitute_args,
35};
36pub use skills::{
37 LoadSkillsFromDirOptions, LoadSkillsOptions, LoadSkillsResult, Skill, expand_skill_invocation,
38 format_skills_for_prompt, load_skills, load_skills_from_dir,
39};
40pub use slash::{
41 BUILTIN_SLASH_COMMAND_COUNT, BuiltinSlashCommand, SlashCommandInfo, SlashCommandSource,
42 builtin_quit_description, builtin_slash_commands,
43};
44pub use source_info::{
45 SourceInfo, SourceOrigin, SourceScope, SyntheticSourceInfoOptions, create_source_info,
46 create_synthetic_source_info,
47};
48pub use themes::{
49 LoadThemesOptions, LoadThemesResult, LoadedTheme, load_theme_from_file, load_themes,
50 load_themes_from_dir,
51};
52
53use std::collections::HashMap;
54use std::future::Future;
55
56use std::fs;
57use std::path::{Path, PathBuf};
58
59use thiserror::Error;
60
61use crate::core::config::{
62 CONFIG_DIR_NAME, PathInputOptions, canonicalize_path, is_local_path, resolve_path_with,
63};
64use crate::core::settings::{SettingsManager, SettingsManagerCreateOptions};
65
66#[derive(Clone, Debug, Eq, PartialEq)]
68pub struct ExtensionPathInfo {
69 pub path: String,
71 pub resolved_path: String,
73 pub source_info: SourceInfo,
75}
76
77#[derive(Clone, Debug, Default, PartialEq)]
79pub struct LoadExtensionsResult {
80 pub paths: Vec<ExtensionPathInfo>,
82 pub errors: Vec<ExtensionLoadError>,
84}
85
86#[derive(Clone, Debug, Eq, PartialEq)]
88pub struct ExtensionLoadError {
89 pub path: String,
91 pub error: String,
93}
94
95#[derive(Clone, Debug, Eq, PartialEq)]
97pub struct AgentsFile {
98 pub path: String,
100 pub content: String,
102}
103
104#[derive(Clone, Debug, Default, Eq, PartialEq)]
106pub struct ResourceExtensionPaths {
107 pub skill_paths: Vec<ExtensionResourcePath>,
109 pub prompt_paths: Vec<ExtensionResourcePath>,
111 pub theme_paths: Vec<ExtensionResourcePath>,
113}
114
115impl ResourceExtensionPaths {
116 #[must_use]
118 pub fn is_empty(&self) -> bool {
119 self.skill_paths.is_empty() && self.prompt_paths.is_empty() && self.theme_paths.is_empty()
120 }
121}
122
123#[derive(Clone, Debug, Eq, PartialEq)]
125pub struct ExtensionResourcePath {
126 pub path: String,
128 pub metadata: PathMetadata,
130}
131
132impl ExtensionResourcePath {
133 #[must_use]
135 pub fn discovered(path: String, extension_path: &str) -> Self {
136 let synthetic = extension_path.starts_with('<');
137 let label = if synthetic {
138 extension_path.trim_matches(['<', '>']).to_owned()
139 } else {
140 Path::new(extension_path)
141 .file_stem()
142 .and_then(|name| name.to_str())
143 .unwrap_or(extension_path)
144 .to_owned()
145 };
146 Self {
147 path,
148 metadata: PathMetadata {
149 source: format!("extension:{label}"),
150 scope: source_info::SourceScope::Temporary,
151 origin: source_info::SourceOrigin::TopLevel,
152 base_dir: (!synthetic).then(|| {
153 Path::new(extension_path)
154 .parent()
155 .map_or_else(String::new, path_to_string)
156 }),
157 },
158 }
159 }
160}
161
162pub type ResourceLoadingDisabled = bool;
166
167pub struct DefaultResourceLoaderOptions {
169 pub cwd: PathBuf,
171 pub agent_dir: PathBuf,
173 pub settings_manager: Option<SettingsManager>,
175 pub additional_extension_paths: Vec<String>,
177 pub additional_skill_paths: Vec<String>,
179 pub additional_prompt_template_paths: Vec<String>,
181 pub additional_theme_paths: Vec<String>,
183 pub no_extensions: ResourceLoadingDisabled,
185 pub no_skills: ResourceLoadingDisabled,
187 pub no_prompt_templates: ResourceLoadingDisabled,
189 pub no_themes: ResourceLoadingDisabled,
191 pub no_context_files: ResourceLoadingDisabled,
193 pub system_prompt: Option<String>,
195 pub append_system_prompt: Option<Vec<String>>,
197}
198
199impl Default for DefaultResourceLoaderOptions {
200 fn default() -> Self {
201 Self {
202 cwd: PathBuf::from("."),
203 agent_dir: crate::core::config::get_agent_dir(),
204 settings_manager: None,
205 additional_extension_paths: Vec::new(),
206 additional_skill_paths: Vec::new(),
207 additional_prompt_template_paths: Vec::new(),
208 additional_theme_paths: Vec::new(),
209 no_extensions: false,
210 no_skills: false,
211 no_prompt_templates: false,
212 no_themes: false,
213 no_context_files: false,
214 system_prompt: None,
215 append_system_prompt: None,
216 }
217 }
218}
219
220#[derive(Debug, Error)]
222pub enum ResourceLoaderError {
223 #[error(transparent)]
225 Package(#[from] PackageResolveError),
226 #[error("resource resolve task failed: {0}")]
228 Join(String),
229}
230
231pub trait ResourceLoader {
233 fn get_extensions(&self) -> &LoadExtensionsResult;
235 fn get_skills(&self) -> (&[Skill], &[ResourceDiagnostic]);
237 fn get_prompts(&self) -> (&[PromptTemplate], &[ResourceDiagnostic]);
239 fn get_themes(&self) -> (&[LoadedTheme], &[ResourceDiagnostic]);
241 fn get_agents_files(&self) -> &[AgentsFile];
243 fn get_system_prompt(&self) -> Option<&str>;
245 fn get_append_system_prompt(&self) -> &[String];
247 fn extend_resources(&mut self, paths: ResourceExtensionPaths);
249 fn reload(&mut self) -> impl Future<Output = Result<(), ResourceLoaderError>>;
256}
257#[derive(Clone, Debug, Default)]
259struct ResourceSnapshot {
260 extensions: LoadExtensionsResult,
261 skills: Vec<Skill>,
262 skill_diagnostics: Vec<ResourceDiagnostic>,
263 prompts: Vec<PromptTemplate>,
264 prompt_diagnostics: Vec<ResourceDiagnostic>,
265 themes: Vec<LoadedTheme>,
266 theme_diagnostics: Vec<ResourceDiagnostic>,
267 agents_files: Vec<AgentsFile>,
268 system_prompt: Option<String>,
269 append_system_prompt: Vec<String>,
270 last_skill_paths: Vec<String>,
271 last_prompt_paths: Vec<String>,
272 last_theme_paths: Vec<String>,
273}
274
275#[derive(Clone, Copy, Debug, Default)]
276struct ResourceDiscoveryPolicy(u8);
277
278impl ResourceDiscoveryPolicy {
279 const EXTENSIONS: u8 = 1 << 0;
280 const SKILLS: u8 = 1 << 1;
281 const PROMPT_TEMPLATES: u8 = 1 << 2;
282 const THEMES: u8 = 1 << 3;
283 const CONTEXT_FILES: u8 = 1 << 4;
284
285 fn from_options(options: &DefaultResourceLoaderOptions) -> Self {
286 let mut bits = 0;
287 for (disabled, flag) in [
288 (options.no_extensions, Self::EXTENSIONS),
289 (options.no_skills, Self::SKILLS),
290 (options.no_prompt_templates, Self::PROMPT_TEMPLATES),
291 (options.no_themes, Self::THEMES),
292 (options.no_context_files, Self::CONTEXT_FILES),
293 ] {
294 if disabled {
295 bits |= flag;
296 }
297 }
298 Self(bits)
299 }
300
301 const fn disables(self, flag: u8) -> bool {
302 self.0 & flag != 0
303 }
304}
305
306#[derive(Default)]
307struct EnabledResourcePaths {
308 extensions: Vec<String>,
309 skills: Vec<String>,
310 prompts: Vec<String>,
311 themes: Vec<String>,
312}
313
314struct ReloadDiscovery {
315 metadata_by_path: HashMap<String, PathMetadata>,
316 package: EnabledResourcePaths,
317 cli: EnabledResourcePaths,
318}
319
320pub struct DefaultResourceLoader {
322 cwd: PathBuf,
323 agent_dir: PathBuf,
324 settings_manager: SettingsManager,
325 additional_extension_paths: Vec<String>,
326 additional_skill_paths: Vec<String>,
327 additional_prompt_template_paths: Vec<String>,
328 additional_theme_paths: Vec<String>,
329 base_skill_paths: Vec<String>,
330 base_prompt_paths: Vec<String>,
331 base_theme_paths: Vec<String>,
332 extension_skill_paths: Vec<String>,
333 extension_prompt_paths: Vec<String>,
334 extension_theme_paths: Vec<String>,
335 discovery: ResourceDiscoveryPolicy,
336 system_prompt_source: Option<String>,
337 append_system_prompt_source: Option<Vec<String>>,
338 extension_skill_source_infos: HashMap<String, SourceInfo>,
339 extension_prompt_source_infos: HashMap<String, SourceInfo>,
340 extension_theme_source_infos: HashMap<String, SourceInfo>,
341 snapshot: ResourceSnapshot,
342 loaded: bool,
343}
344
345impl DefaultResourceLoader {
346 #[must_use]
348 pub fn new(options: DefaultResourceLoaderOptions) -> Self {
349 let cwd = resolve_path_with(
350 &path_to_string(&options.cwd),
351 Path::new("."),
352 PathInputOptions::new(),
353 );
354 let agent_dir = resolve_path_with(
355 &path_to_string(&options.agent_dir),
356 Path::new("."),
357 PathInputOptions::new(),
358 );
359 let discovery = ResourceDiscoveryPolicy::from_options(&options);
360 let settings_manager = options.settings_manager.unwrap_or_else(|| {
361 SettingsManager::create(
362 &cwd,
363 Some(&agent_dir),
364 SettingsManagerCreateOptions::default(),
365 )
366 });
367 Self {
368 cwd,
369 agent_dir,
370 settings_manager,
371 additional_extension_paths: options.additional_extension_paths,
372 additional_skill_paths: options.additional_skill_paths,
373 additional_prompt_template_paths: options.additional_prompt_template_paths,
374 additional_theme_paths: options.additional_theme_paths,
375 base_skill_paths: Vec::new(),
376 base_prompt_paths: Vec::new(),
377 base_theme_paths: Vec::new(),
378 extension_skill_paths: Vec::new(),
379 extension_prompt_paths: Vec::new(),
380 extension_theme_paths: Vec::new(),
381 discovery,
382 system_prompt_source: options.system_prompt,
383 append_system_prompt_source: options.append_system_prompt,
384 extension_skill_source_infos: HashMap::new(),
385 extension_prompt_source_infos: HashMap::new(),
386 extension_theme_source_infos: HashMap::new(),
387 snapshot: ResourceSnapshot::default(),
388 loaded: false,
389 }
390 }
391
392 pub fn settings_manager_mut(&mut self) -> &mut SettingsManager {
394 &mut self.settings_manager
395 }
396
397 #[must_use]
399 pub fn settings_manager(&self) -> &SettingsManager {
400 &self.settings_manager
401 }
402
403 fn resolve_resource_path(&self, path: &str) -> PathBuf {
404 resolve_path_with(path, &self.cwd, PathInputOptions::new().trim(true))
405 }
406
407 fn merge_paths(&self, primary: &[String], additional: &[String]) -> Vec<String> {
408 let mut merged = Vec::new();
409 let mut seen = std::collections::HashSet::new();
410 for path in primary.iter().chain(additional.iter()) {
411 let resolved = self.resolve_resource_path(path);
412 let canonical = path_to_string(&canonicalize_path(&resolved));
413 if !seen.insert(canonical) {
414 continue;
415 }
416 merged.push(path_to_string(&resolved));
417 }
418 merged
419 }
420
421 fn map_skill_path(
422 resource: &ResolvedResource,
423 metadata_by_path: &mut HashMap<String, PathMetadata>,
424 ) -> String {
425 if resource.metadata.source != "auto" && resource.metadata.origin != SourceOrigin::Package {
426 return resource.path.clone();
427 }
428 let path = Path::new(&resource.path);
429 let Ok(meta) = fs::metadata(path) else {
430 return resource.path.clone();
431 };
432 if !meta.is_dir() {
433 return resource.path.clone();
434 }
435 let skill_file = path.join("SKILL.md");
436 if skill_file.exists() {
437 let skill_path = path_to_string(&skill_file);
438 metadata_by_path
439 .entry(skill_path.clone())
440 .or_insert_with(|| resource.metadata.clone());
441 return skill_path;
442 }
443 resource.path.clone()
444 }
445
446 fn update_skills_from_paths(
447 &mut self,
448 skill_paths: &[String],
449 metadata_by_path: Option<&HashMap<String, PathMetadata>>,
450 ) {
451 let result =
452 if self.discovery.disables(ResourceDiscoveryPolicy::SKILLS) && skill_paths.is_empty() {
453 LoadSkillsResult::default()
454 } else {
455 load_skills(&LoadSkillsOptions {
456 cwd: self.cwd.clone(),
457 agent_dir: self.agent_dir.clone(),
458 skill_paths: skill_paths.to_vec(),
459 include_defaults: false,
460 })
461 };
462 self.snapshot.skills = result
463 .skills
464 .into_iter()
465 .map(|mut skill| {
466 let existing = skill.source_info.clone();
467 skill.source_info = self
468 .find_source_info_for_path(
469 &skill.file_path,
470 Some(&self.extension_skill_source_infos),
471 metadata_by_path,
472 )
473 .unwrap_or(existing);
474 skill
475 })
476 .collect();
477 self.snapshot.skill_diagnostics = result.diagnostics;
478 }
479
480 fn update_prompts_from_paths(
481 &mut self,
482 prompt_paths: &[String],
483 metadata_by_path: Option<&HashMap<String, PathMetadata>>,
484 ) {
485 let (prompts, mut diagnostics) = if self
486 .discovery
487 .disables(ResourceDiscoveryPolicy::PROMPT_TEMPLATES)
488 && prompt_paths.is_empty()
489 {
490 (Vec::new(), Vec::new())
491 } else {
492 let all = load_prompt_templates(&LoadPromptTemplatesOptions {
493 cwd: self.cwd.clone(),
494 agent_dir: self.agent_dir.clone(),
495 prompt_paths: prompt_paths.to_vec(),
496 include_defaults: false,
497 });
498 dedupe_prompts(all)
499 };
500 self.snapshot.prompts = prompts
501 .into_iter()
502 .map(|mut prompt| {
503 let existing = prompt.source_info.clone();
504 prompt.source_info = self
505 .find_source_info_for_path(
506 &prompt.file_path,
507 Some(&self.extension_prompt_source_infos),
508 metadata_by_path,
509 )
510 .unwrap_or(existing);
511 prompt
512 })
513 .collect();
514 self.snapshot.prompt_diagnostics = {
515 let _ = &mut diagnostics;
516 diagnostics
517 };
518 }
519
520 fn update_themes_from_paths(
521 &mut self,
522 theme_paths: &[String],
523 metadata_by_path: Option<&HashMap<String, PathMetadata>>,
524 ) {
525 let result =
526 if self.discovery.disables(ResourceDiscoveryPolicy::THEMES) && theme_paths.is_empty() {
527 LoadThemesResult::default()
528 } else {
529 load_themes(&LoadThemesOptions {
530 cwd: self.cwd.clone(),
531 theme_paths: theme_paths.to_vec(),
532 })
533 };
534 self.snapshot.themes = result
535 .themes
536 .into_iter()
537 .map(|mut theme| {
538 let existing = theme.source_info.clone();
539 theme.source_info = self
540 .find_source_info_for_path(
541 &theme.source_path,
542 Some(&self.extension_theme_source_infos),
543 metadata_by_path,
544 )
545 .unwrap_or(existing);
546 theme
547 })
548 .collect();
549 self.snapshot.theme_diagnostics = result.diagnostics;
550 }
551
552 fn find_source_info_for_path(
553 &self,
554 resource_path: &str,
555 extra: Option<&HashMap<String, SourceInfo>>,
556 metadata_by_path: Option<&HashMap<String, PathMetadata>>,
557 ) -> Option<SourceInfo> {
558 if resource_path.is_empty() {
559 return None;
560 }
561 if resource_path.starts_with('<') {
562 return Some(self.default_source_info_for_path(resource_path));
563 }
564 let normalized = resolve_path_with(resource_path, Path::new("."), PathInputOptions::new());
565 let normalized_str = path_to_string(&normalized);
566 if let Some(extra) = extra {
567 for (source_path, source_info) in extra {
568 let source_resolved =
569 resolve_path_with(source_path, Path::new("."), PathInputOptions::new());
570 if normalized == source_resolved || normalized.starts_with(&source_resolved) {
571 let mut info = source_info.clone();
572 resource_path.clone_into(&mut info.path);
573 return Some(info);
574 }
575 }
576 }
577 if let Some(metadata_by_path) = metadata_by_path {
578 if let Some(meta) = metadata_by_path
579 .get(&normalized_str)
580 .or_else(|| metadata_by_path.get(resource_path))
581 {
582 return Some(create_source_info(resource_path, meta));
583 }
584 for (source_path, metadata) in metadata_by_path {
585 let source_resolved =
586 resolve_path_with(source_path, Path::new("."), PathInputOptions::new());
587 if normalized == source_resolved || normalized.starts_with(&source_resolved) {
588 return Some(create_source_info(resource_path, metadata));
589 }
590 }
591 }
592 None
593 }
594
595 fn default_source_info_for_path(&self, file_path: &str) -> SourceInfo {
596 if file_path.starts_with('<') && file_path.ends_with('>') {
597 let inner = &file_path[1..file_path.len() - 1];
598 let source = inner.split(':').next().unwrap_or("temporary");
599 return SourceInfo {
600 path: file_path.to_owned(),
601 source: source.to_owned(),
602 scope: SourceScope::Temporary,
603 origin: SourceOrigin::TopLevel,
604 base_dir: None,
605 };
606 }
607 let normalized = resolve_path_with(file_path, Path::new("."), PathInputOptions::new());
608 let agent_roots = [
609 self.agent_dir.join("skills"),
610 self.agent_dir.join("prompts"),
611 self.agent_dir.join("themes"),
612 self.agent_dir.join("extensions"),
613 ];
614 let project_roots = [
615 self.cwd.join(CONFIG_DIR_NAME).join("skills"),
616 self.cwd.join(CONFIG_DIR_NAME).join("prompts"),
617 self.cwd.join(CONFIG_DIR_NAME).join("themes"),
618 self.cwd.join(CONFIG_DIR_NAME).join("extensions"),
619 ];
620 for root in &agent_roots {
621 if is_under_path(&normalized, root) {
622 return SourceInfo {
623 path: file_path.to_owned(),
624 source: "local".into(),
625 scope: SourceScope::User,
626 origin: SourceOrigin::TopLevel,
627 base_dir: Some(path_to_string(root)),
628 };
629 }
630 }
631 for root in &project_roots {
632 if is_under_path(&normalized, root) {
633 return SourceInfo {
634 path: file_path.to_owned(),
635 source: "local".into(),
636 scope: SourceScope::Project,
637 origin: SourceOrigin::TopLevel,
638 base_dir: Some(path_to_string(root)),
639 };
640 }
641 }
642 let base_dir = if normalized.is_dir() {
643 path_to_string(&normalized)
644 } else {
645 normalized
646 .parent()
647 .map_or_else(|| path_to_string(&normalized), path_to_string)
648 };
649 SourceInfo {
650 path: file_path.to_owned(),
651 source: "local".into(),
652 scope: SourceScope::Temporary,
653 origin: SourceOrigin::TopLevel,
654 base_dir: Some(base_dir),
655 }
656 }
657
658 fn discover_system_prompt_file(&self) -> Option<String> {
659 let project_path = self.cwd.join(CONFIG_DIR_NAME).join("SYSTEM.md");
660 if self.settings_manager.is_project_trusted() && project_path.exists() {
661 return Some(path_to_string(&project_path));
662 }
663 let global_path = self.agent_dir.join("SYSTEM.md");
664 if global_path.exists() {
665 return Some(path_to_string(&global_path));
666 }
667 None
668 }
669
670 fn discover_append_system_prompt_file(&self) -> Option<String> {
671 let project_path = self.cwd.join(CONFIG_DIR_NAME).join("APPEND_SYSTEM.md");
672 if self.settings_manager.is_project_trusted() && project_path.exists() {
673 return Some(path_to_string(&project_path));
674 }
675 let global_path = self.agent_dir.join("APPEND_SYSTEM.md");
676 if global_path.exists() {
677 return Some(path_to_string(&global_path));
678 }
679 None
680 }
681
682 fn normalize_extension_paths(
683 &self,
684 entries: &[ExtensionResourcePath],
685 ) -> Vec<ExtensionResourcePath> {
686 entries
687 .iter()
688 .map(|entry| {
689 let mut metadata = entry.metadata.clone();
690 let base = metadata
691 .base_dir
692 .as_deref()
693 .map(|base| self.resolve_resource_path(base));
694 metadata.base_dir = base.as_deref().map(path_to_string);
695 let path = resolve_path_with(
696 &entry.path,
697 base.as_deref().unwrap_or(&self.cwd),
698 PathInputOptions::new(),
699 );
700 ExtensionResourcePath {
701 path: path_to_string(&path),
702 metadata,
703 }
704 })
705 .collect()
706 }
707
708 async fn resolve_reload_discovery(&self) -> Result<ReloadDiscovery, ResourceLoaderError> {
709 let cwd = self.cwd.clone();
710 let agent_dir = self.agent_dir.clone();
711 let project_trusted = self.settings_manager.is_project_trusted();
712 let additional_extension_paths = self.additional_extension_paths.clone();
713 let (resolved, cli) = tokio::task::spawn_blocking(move || {
714 let settings = SettingsManager::create(
715 &cwd,
716 Some(&agent_dir),
717 SettingsManagerCreateOptions::new().project_trusted(project_trusted),
718 );
719 let resolver = PackagePathResolver::new(&cwd, &agent_dir, &settings);
720 let packages = resolver.resolve()?;
721 let cli =
722 resolver.resolve_extension_sources(&additional_extension_paths, true, false)?;
723 Ok::<_, PackageResolveError>((packages, cli))
724 })
725 .await
726 .map_err(|error| ResourceLoaderError::Join(error.to_string()))??;
727 Ok(Self::collect_reload_discovery(&resolved, &cli))
728 }
729
730 fn collect_reload_discovery(resolved: &ResolvedPaths, cli: &ResolvedPaths) -> ReloadDiscovery {
731 let mut metadata_by_path = HashMap::new();
732 for resources in [
733 &resolved.extensions,
734 &resolved.skills,
735 &resolved.prompts,
736 &resolved.themes,
737 ] {
738 for resource in resources {
739 metadata_by_path
740 .entry(resource.path.clone())
741 .or_insert_with(|| resource.metadata.clone());
742 }
743 }
744 for resource in cli.extensions.iter().chain(&cli.skills) {
745 metadata_by_path
746 .entry(resource.path.clone())
747 .or_insert(PathMetadata {
748 source: "cli".into(),
749 scope: SourceScope::Temporary,
750 origin: SourceOrigin::TopLevel,
751 base_dir: None,
752 });
753 }
754 let mut package = EnabledResourcePaths {
755 extensions: enabled_paths(&resolved.extensions),
756 prompts: enabled_paths(&resolved.prompts),
757 themes: enabled_paths(&resolved.themes),
758 ..EnabledResourcePaths::default()
759 };
760 package.skills = resolved
761 .skills
762 .iter()
763 .filter(|resource| resource.enabled)
764 .map(|resource| Self::map_skill_path(resource, &mut metadata_by_path))
765 .collect();
766 let cli = EnabledResourcePaths {
767 extensions: enabled_paths(&cli.extensions),
768 skills: enabled_paths(&cli.skills),
769 prompts: enabled_paths(&cli.prompts),
770 themes: enabled_paths(&cli.themes),
771 };
772 ReloadDiscovery {
773 metadata_by_path,
774 package,
775 cli,
776 }
777 }
778
779 fn load_extension_phase(&mut self, discovery: &ReloadDiscovery) {
780 let paths = if self.discovery.disables(ResourceDiscoveryPolicy::EXTENSIONS) {
781 discovery.cli.extensions.clone()
782 } else {
783 self.merge_paths(&discovery.cli.extensions, &discovery.package.extensions)
784 };
785 let mut result = LoadExtensionsResult {
786 paths: paths
787 .iter()
788 .map(|path| ExtensionPathInfo {
789 path: path.clone(),
790 resolved_path: path_to_string(&self.resolve_resource_path(path)),
791 source_info: discovery.metadata_by_path.get(path).map_or_else(
792 || self.default_source_info_for_path(path),
793 |metadata| create_source_info(path, metadata),
794 ),
795 })
796 .collect(),
797 errors: Vec::new(),
798 };
799 for path in &self.additional_extension_paths {
800 if is_local_path(path) {
801 let resolved = self.resolve_resource_path(path);
802 if !resolved.exists() {
803 let resolved = path_to_string(&resolved);
804 result.errors.push(ExtensionLoadError {
805 path: resolved.clone(),
806 error: format!("Extension path does not exist: {resolved}"),
807 });
808 }
809 }
810 }
811 self.snapshot.extensions = result;
812 }
813
814 fn load_skill_phase(&mut self, discovery: &ReloadDiscovery) {
815 let base_paths = if self.discovery.disables(ResourceDiscoveryPolicy::SKILLS) {
816 self.merge_paths(&discovery.cli.skills, &self.additional_skill_paths)
817 } else {
818 let mut primary = discovery.cli.skills.clone();
819 primary.extend(discovery.package.skills.iter().cloned());
820 self.merge_paths(&primary, &self.additional_skill_paths)
821 };
822 self.base_skill_paths.clone_from(&base_paths);
823 let paths = self.merge_paths(&base_paths, &self.extension_skill_paths);
824 self.snapshot.last_skill_paths.clone_from(&paths);
825 self.update_skills_from_paths(&paths, Some(&discovery.metadata_by_path));
826 for path in &self.additional_skill_paths {
827 if is_local_path(path) {
828 let resolved = path_to_string(&self.resolve_resource_path(path));
829 if !Path::new(&resolved).exists()
830 && !self
831 .snapshot
832 .skill_diagnostics
833 .iter()
834 .any(|diagnostic| diagnostic.path.as_deref() == Some(resolved.as_str()))
835 {
836 self.snapshot
837 .skill_diagnostics
838 .push(ResourceDiagnostic::error(
839 "Skill path does not exist",
840 Some(resolved),
841 ));
842 }
843 }
844 }
845 }
846
847 fn load_prompt_phase(&mut self, discovery: &ReloadDiscovery) {
848 let base_paths = if self
849 .discovery
850 .disables(ResourceDiscoveryPolicy::PROMPT_TEMPLATES)
851 {
852 self.merge_paths(
853 &discovery.cli.prompts,
854 &self.additional_prompt_template_paths,
855 )
856 } else {
857 let mut primary = discovery.cli.prompts.clone();
858 primary.extend(discovery.package.prompts.iter().cloned());
859 self.merge_paths(&primary, &self.additional_prompt_template_paths)
860 };
861 self.base_prompt_paths.clone_from(&base_paths);
862 let paths = self.merge_paths(&base_paths, &self.extension_prompt_paths);
863 self.snapshot.last_prompt_paths.clone_from(&paths);
864 self.update_prompts_from_paths(&paths, Some(&discovery.metadata_by_path));
865 for path in &self.additional_prompt_template_paths {
866 if is_local_path(path) {
867 let resolved = path_to_string(&self.resolve_resource_path(path));
868 if !Path::new(&resolved).exists()
869 && !self
870 .snapshot
871 .prompt_diagnostics
872 .iter()
873 .any(|diagnostic| diagnostic.path.as_deref() == Some(resolved.as_str()))
874 {
875 self.snapshot
876 .prompt_diagnostics
877 .push(ResourceDiagnostic::error(
878 "Prompt template path does not exist",
879 Some(resolved),
880 ));
881 }
882 }
883 }
884 }
885
886 fn load_theme_phase(&mut self, discovery: &ReloadDiscovery) {
887 let base_paths = if self.discovery.disables(ResourceDiscoveryPolicy::THEMES) {
888 self.merge_paths(&discovery.cli.themes, &self.additional_theme_paths)
889 } else {
890 let mut primary = discovery.cli.themes.clone();
891 primary.extend(discovery.package.themes.iter().cloned());
892 self.merge_paths(&primary, &self.additional_theme_paths)
893 };
894 self.base_theme_paths.clone_from(&base_paths);
895 let paths = self.merge_paths(&base_paths, &self.extension_theme_paths);
896 self.snapshot.last_theme_paths.clone_from(&paths);
897 self.update_themes_from_paths(&paths, Some(&discovery.metadata_by_path));
898 for path in &self.additional_theme_paths {
899 let resolved = path_to_string(&self.resolve_resource_path(path));
900 if !Path::new(&resolved).exists()
901 && !self
902 .snapshot
903 .theme_diagnostics
904 .iter()
905 .any(|diagnostic| diagnostic.path.as_deref() == Some(resolved.as_str()))
906 {
907 self.snapshot
908 .theme_diagnostics
909 .push(ResourceDiagnostic::error(
910 "Theme path does not exist",
911 Some(resolved),
912 ));
913 }
914 }
915 }
916
917 fn load_context_and_prompts_phase(&mut self) {
918 self.snapshot.agents_files = if self
919 .discovery
920 .disables(ResourceDiscoveryPolicy::CONTEXT_FILES)
921 {
922 Vec::new()
923 } else {
924 load_project_context_files(&self.cwd, &self.agent_dir)
925 };
926 self.snapshot.system_prompt = resolve_prompt_input(
927 self.system_prompt_source
928 .clone()
929 .or_else(|| self.discover_system_prompt_file()),
930 );
931 let append_sources = self.append_system_prompt_source.clone().unwrap_or_else(|| {
932 self.discover_append_system_prompt_file()
933 .into_iter()
934 .collect()
935 });
936 self.snapshot.append_system_prompt = append_sources
937 .into_iter()
938 .filter_map(|source| resolve_prompt_input(Some(source)))
939 .collect();
940 }
941}
942
943impl ResourceLoader for DefaultResourceLoader {
944 fn get_extensions(&self) -> &LoadExtensionsResult {
945 &self.snapshot.extensions
946 }
947
948 fn get_skills(&self) -> (&[Skill], &[ResourceDiagnostic]) {
949 (&self.snapshot.skills, &self.snapshot.skill_diagnostics)
950 }
951
952 fn get_prompts(&self) -> (&[PromptTemplate], &[ResourceDiagnostic]) {
953 (&self.snapshot.prompts, &self.snapshot.prompt_diagnostics)
954 }
955
956 fn get_themes(&self) -> (&[LoadedTheme], &[ResourceDiagnostic]) {
957 (&self.snapshot.themes, &self.snapshot.theme_diagnostics)
958 }
959
960 fn get_agents_files(&self) -> &[AgentsFile] {
961 &self.snapshot.agents_files
962 }
963
964 fn get_system_prompt(&self) -> Option<&str> {
965 self.snapshot.system_prompt.as_deref()
966 }
967
968 fn get_append_system_prompt(&self) -> &[String] {
969 &self.snapshot.append_system_prompt
970 }
971
972 fn extend_resources(&mut self, paths: ResourceExtensionPaths) {
973 let skill_paths = self.normalize_extension_paths(&paths.skill_paths);
974 let prompt_paths = self.normalize_extension_paths(&paths.prompt_paths);
975 let theme_paths = self.normalize_extension_paths(&paths.theme_paths);
976
977 self.extension_skill_paths = path_strings(&skill_paths);
978 self.extension_prompt_paths = path_strings(&prompt_paths);
979 self.extension_theme_paths = path_strings(&theme_paths);
980
981 self.extension_skill_source_infos = source_infos(&skill_paths);
982 self.extension_prompt_source_infos = source_infos(&prompt_paths);
983 self.extension_theme_source_infos = source_infos(&theme_paths);
984
985 self.snapshot.last_skill_paths =
986 self.merge_paths(&self.base_skill_paths, &self.extension_skill_paths);
987 let skill_snapshot = self.snapshot.last_skill_paths.clone();
988 self.update_skills_from_paths(&skill_snapshot, None);
989
990 self.snapshot.last_prompt_paths =
991 self.merge_paths(&self.base_prompt_paths, &self.extension_prompt_paths);
992 let prompt_snapshot = self.snapshot.last_prompt_paths.clone();
993 self.update_prompts_from_paths(&prompt_snapshot, None);
994
995 self.snapshot.last_theme_paths =
996 self.merge_paths(&self.base_theme_paths, &self.extension_theme_paths);
997 let theme_snapshot = self.snapshot.last_theme_paths.clone();
998 self.update_themes_from_paths(&theme_snapshot, None);
999 }
1000
1001 async fn reload(&mut self) -> Result<(), ResourceLoaderError> {
1002 let _ = self.loaded;
1003 self.settings_manager.reload();
1004 let discovery = self.resolve_reload_discovery().await?;
1005 self.extension_skill_source_infos.clear();
1006 self.extension_prompt_source_infos.clear();
1007 self.extension_theme_source_infos.clear();
1008 self.load_extension_phase(&discovery);
1009 self.load_skill_phase(&discovery);
1010 self.load_prompt_phase(&discovery);
1011 self.load_theme_phase(&discovery);
1012 self.load_context_and_prompts_phase();
1013 self.loaded = true;
1014 Ok(())
1015 }
1016}
1017
1018#[must_use]
1022pub fn load_project_context_files(cwd: &Path, agent_dir: &Path) -> Vec<AgentsFile> {
1023 let resolved_cwd = resolve_path_with(
1024 &path_to_string(cwd),
1025 Path::new("."),
1026 PathInputOptions::new(),
1027 );
1028 let resolved_agent_dir = resolve_path_with(
1029 &path_to_string(agent_dir),
1030 Path::new("."),
1031 PathInputOptions::new(),
1032 );
1033 let mut context_files = Vec::new();
1034 let mut seen_paths = std::collections::HashSet::new();
1035
1036 if let Some(global) = load_context_file_from_dir(&resolved_agent_dir) {
1037 seen_paths.insert(global.path.clone());
1038 context_files.push(global);
1039 }
1040
1041 let mut ancestor = Vec::new();
1042 let mut current = resolved_cwd;
1043 loop {
1044 if let Some(context) = load_context_file_from_dir(¤t)
1045 && seen_paths.insert(context.path.clone())
1046 {
1047 ancestor.insert(0, context);
1048 }
1049 match current.parent() {
1050 Some(parent) if parent != current => current = parent.to_path_buf(),
1051 _ => break,
1052 }
1053 }
1054 context_files.extend(ancestor);
1055 context_files
1056}
1057
1058fn load_context_file_from_dir(dir: &Path) -> Option<AgentsFile> {
1059 for filename in ["AGENTS.md", "AGENTS.MD", "CLAUDE.md", "CLAUDE.MD"] {
1060 let file_path = dir.join(filename);
1061 if file_path.exists()
1062 && let Ok(content) = fs::read_to_string(&file_path)
1063 {
1064 return Some(AgentsFile {
1065 path: path_to_string(&file_path),
1066 content,
1067 });
1068 }
1069 }
1070 None
1071}
1072
1073fn resolve_prompt_input(input: Option<String>) -> Option<String> {
1074 let input = input?;
1075 let path = Path::new(&input);
1076 if path.exists() {
1077 match fs::read_to_string(path) {
1078 Ok(content) => Some(content),
1079 Err(_) => Some(input),
1080 }
1081 } else {
1082 Some(input)
1083 }
1084}
1085
1086fn dedupe_prompts(prompts: Vec<PromptTemplate>) -> (Vec<PromptTemplate>, Vec<ResourceDiagnostic>) {
1087 let mut seen: HashMap<String, PromptTemplate> = HashMap::new();
1088 let mut order = Vec::new();
1089 let mut diagnostics = Vec::new();
1090 for prompt in prompts {
1091 if let Some(existing) = seen.get(&prompt.name) {
1092 diagnostics.push(ResourceDiagnostic::collision(
1093 format!("name \"/{}\" collision", prompt.name),
1094 Some(prompt.file_path.clone()),
1095 ResourceCollision {
1096 resource_type: ResourceType::Prompt,
1097 name: prompt.name.clone(),
1098 winner_path: existing.file_path.clone(),
1099 loser_path: prompt.file_path.clone(),
1100 winner_source: None,
1101 loser_source: None,
1102 },
1103 ));
1104 } else {
1105 order.push(prompt.name.clone());
1106 seen.insert(prompt.name.clone(), prompt);
1107 }
1108 }
1109 let prompts = order
1110 .into_iter()
1111 .filter_map(|name| seen.remove(&name))
1112 .collect();
1113 (prompts, diagnostics)
1114}
1115
1116fn path_strings(paths: &[ExtensionResourcePath]) -> Vec<String> {
1117 paths.iter().map(|entry| entry.path.clone()).collect()
1118}
1119
1120fn source_infos(paths: &[ExtensionResourcePath]) -> HashMap<String, SourceInfo> {
1121 paths
1122 .iter()
1123 .map(|entry| {
1124 (
1125 entry.path.clone(),
1126 create_source_info(&entry.path, &entry.metadata),
1127 )
1128 })
1129 .collect()
1130}
1131
1132fn is_under_path(target: &Path, root: &Path) -> bool {
1133 let normalized_root = resolve_path_with(
1134 &path_to_string(root),
1135 Path::new("."),
1136 PathInputOptions::new(),
1137 );
1138 let normalized_target = resolve_path_with(
1139 &path_to_string(target),
1140 Path::new("."),
1141 PathInputOptions::new(),
1142 );
1143 if normalized_target == normalized_root {
1144 return true;
1145 }
1146 let root_str = path_to_string(&normalized_root);
1148 let target_str = path_to_string(&normalized_target);
1149 let sep = std::path::MAIN_SEPARATOR;
1150 let prefix = if root_str.ends_with(sep) {
1151 root_str
1152 } else {
1153 format!("{root_str}{sep}")
1154 };
1155 target_str.starts_with(&prefix)
1156}
1157
1158fn enabled_paths(resources: &[ResolvedResource]) -> Vec<String> {
1159 resources
1160 .iter()
1161 .filter(|resource| resource.enabled)
1162 .map(|resource| resource.path.clone())
1163 .collect()
1164}
1165
1166#[cfg(test)]
1167mod tests {
1168 use super::*;
1169 use crate::core::settings::SettingsManagerCreateOptions;
1170 use std::time::{SystemTime, UNIX_EPOCH};
1171
1172 fn temp_root(label: &str) -> std::io::Result<PathBuf> {
1173 let nanos = SystemTime::now()
1174 .duration_since(UNIX_EPOCH)
1175 .map_or(0, |duration| duration.as_nanos());
1176 let root = std::env::temp_dir().join(format!("pi-resloader-{label}-{nanos}"));
1177 let _ = fs::remove_dir_all(&root);
1178 fs::create_dir_all(&root)?;
1179 Ok(root)
1180 }
1181
1182 #[tokio::test]
1183 async fn reload_loads_skills_prompts_themes_and_context()
1184 -> Result<(), Box<dyn std::error::Error>> {
1185 let root = temp_root("reload")?;
1186 let cwd = root.join("project");
1187 let agent = root.join("agent");
1188 fs::create_dir_all(agent.join("skills").join("demo"))?;
1189 fs::create_dir_all(agent.join("prompts"))?;
1190 fs::create_dir_all(agent.join("themes"))?;
1191 fs::create_dir_all(&cwd)?;
1192 fs::write(
1193 agent.join("skills").join("demo").join("SKILL.md"),
1194 "---\nname: demo\ndescription: d\n---\nbody\n",
1195 )?;
1196 fs::write(
1197 agent.join("prompts").join("hello.md"),
1198 "---\ndescription: hi\n---\nHello $1\n",
1199 )?;
1200 fs::write(agent.join("themes").join("t.json"), r#"{"name":"t"}"#)?;
1201 fs::write(agent.join("AGENTS.md"), "global agents\n")?;
1202 fs::write(cwd.join("AGENTS.md"), "project agents\n")?;
1203 fs::write(agent.join("SYSTEM.md"), "system\n")?;
1204 fs::write(agent.join("APPEND_SYSTEM.md"), "append\n")?;
1205
1206 let settings = SettingsManager::create(
1207 &cwd,
1208 Some(&agent),
1209 SettingsManagerCreateOptions::new().project_trusted(true),
1210 );
1211 let mut loader = DefaultResourceLoader::new(DefaultResourceLoaderOptions {
1212 cwd: cwd.clone(),
1213 agent_dir: agent.clone(),
1214 settings_manager: Some(settings),
1215 ..Default::default()
1216 });
1217 loader.reload().await?;
1218
1219 let (skills, _) = loader.get_skills();
1220 assert!(skills.iter().any(|s| s.name == "demo"));
1221 let (prompts, _) = loader.get_prompts();
1222 assert!(prompts.iter().any(|p| p.name == "hello"));
1223 let (themes, _) = loader.get_themes();
1224 assert!(themes.iter().any(|t| t.name == "t"));
1225 let agents = loader.get_agents_files();
1226 assert!(agents.len() >= 2);
1227 assert_eq!(loader.get_system_prompt(), Some("system\n"));
1228 assert_eq!(loader.get_append_system_prompt(), &["append\n".to_owned()]);
1229 let _ = fs::remove_dir_all(root);
1230 Ok(())
1231 }
1232
1233 struct ExtensionFixture {
1234 root: PathBuf,
1235 cwd: PathBuf,
1236 agent: PathBuf,
1237 extension_dir: PathBuf,
1238 base_skill: PathBuf,
1239 extension_path: PathBuf,
1240 }
1241
1242 fn setup_extension_fixture() -> Result<ExtensionFixture, Box<dyn std::error::Error>> {
1243 let root = temp_root("extension-resources")?;
1244 let cwd = root.join("project");
1245 let agent = root.join("agent");
1246 let extension_dir = root.join("outside").join("extension");
1247 let base_skill = extension_dir.join("base-skill");
1248 let extra_skill = extension_dir.join("extra-skill");
1249 fs::create_dir_all(&cwd)?;
1250 fs::create_dir_all(&agent)?;
1251 fs::create_dir_all(&base_skill)?;
1252 fs::create_dir_all(&extra_skill)?;
1253 fs::write(
1254 base_skill.join("SKILL.md"),
1255 "---\nname: base\ndescription: base\n---\nbase\n",
1256 )?;
1257 fs::write(
1258 extra_skill.join("SKILL.md"),
1259 "---\nname: extra\ndescription: extra\n---\nextra\n",
1260 )?;
1261 fs::write(
1262 extension_dir.join("prompt.md"),
1263 "---\ndescription: prompt\n---\nPrompt\n",
1264 )?;
1265 fs::write(extension_dir.join("theme.json"), r#"{"name":"extension"}"#)?;
1266 let extension_path = extension_dir.join("plugin.ts");
1267 fs::write(&extension_path, "")?;
1268
1269 Ok(ExtensionFixture {
1270 root,
1271 cwd,
1272 agent,
1273 extension_dir,
1274 base_skill,
1275 extension_path,
1276 })
1277 }
1278
1279 fn assert_extension_resources_loaded(
1280 loader: &DefaultResourceLoader,
1281 extension_dir: &std::path::Path,
1282 ) {
1283 let ext_dir_str = path_to_string(extension_dir);
1284 assert!(loader.get_skills().0.iter().any(|skill| {
1285 skill.name == "extra"
1286 && skill.source_info.source == "extension:plugin"
1287 && skill.source_info.scope == SourceScope::Temporary
1288 && skill.source_info.base_dir.as_deref() == Some(&ext_dir_str)
1289 }));
1290 assert!(
1291 loader
1292 .get_prompts()
1293 .0
1294 .iter()
1295 .any(|prompt| prompt.name == "prompt")
1296 );
1297 assert!(
1298 loader
1299 .get_themes()
1300 .0
1301 .iter()
1302 .any(|theme| theme.name == "extension")
1303 );
1304 }
1305
1306 fn assert_empty_replacement_and_base_preservation(loader: &DefaultResourceLoader) {
1307 assert!(
1308 loader
1309 .get_skills()
1310 .0
1311 .iter()
1312 .any(|skill| skill.name == "base")
1313 );
1314 assert!(
1315 !loader
1316 .get_skills()
1317 .0
1318 .iter()
1319 .any(|skill| skill.name == "extra")
1320 );
1321 assert!(
1322 !loader
1323 .get_prompts()
1324 .0
1325 .iter()
1326 .any(|prompt| prompt.name == "prompt")
1327 );
1328 assert!(
1329 !loader
1330 .get_themes()
1331 .0
1332 .iter()
1333 .any(|theme| theme.name == "extension")
1334 );
1335 }
1336
1337 #[tokio::test]
1338 async fn extension_paths_resolve_from_extension_and_replace_cleanly()
1339 -> Result<(), Box<dyn std::error::Error>> {
1340 let fixture = setup_extension_fixture()?;
1341 let extension_path = path_to_string(&fixture.extension_path);
1342
1343 let settings = SettingsManager::create(
1344 &fixture.cwd,
1345 Some(&fixture.agent),
1346 SettingsManagerCreateOptions::new().project_trusted(true),
1347 );
1348 let mut loader = DefaultResourceLoader::new(DefaultResourceLoaderOptions {
1349 cwd: fixture.cwd.clone(),
1350 agent_dir: fixture.agent,
1351 settings_manager: Some(settings),
1352 additional_skill_paths: vec![path_to_string(&fixture.base_skill)],
1353 ..Default::default()
1354 });
1355 loader.reload().await?;
1356 loader.extend_resources(ResourceExtensionPaths {
1357 skill_paths: vec![
1358 ExtensionResourcePath::discovered("base-skill".to_owned(), &extension_path),
1359 ExtensionResourcePath::discovered("extra-skill".to_owned(), &extension_path),
1360 ],
1361 prompt_paths: vec![ExtensionResourcePath::discovered(
1362 "prompt.md".to_owned(),
1363 &extension_path,
1364 )],
1365 theme_paths: vec![ExtensionResourcePath::discovered(
1366 "theme.json".to_owned(),
1367 &extension_path,
1368 )],
1369 });
1370
1371 assert_extension_resources_loaded(&loader, &fixture.extension_dir);
1372
1373 loader.reload().await?;
1374 assert!(
1375 loader
1376 .get_skills()
1377 .0
1378 .iter()
1379 .any(|skill| skill.name == "extra")
1380 );
1381 loader.extend_resources(ResourceExtensionPaths::default());
1382 assert_empty_replacement_and_base_preservation(&loader);
1383
1384 let _ = fs::remove_dir_all(fixture.root);
1385 Ok(())
1386 }
1387
1388 #[test]
1389 fn context_files_not_trust_gated() -> Result<(), Box<dyn std::error::Error>> {
1390 let root = temp_root("ctx")?;
1391 let cwd = root.join("p");
1392 let agent = root.join("a");
1393 fs::create_dir_all(&cwd)?;
1394 fs::create_dir_all(&agent)?;
1395 fs::write(cwd.join("AGENTS.md"), "p\n")?;
1396 let files = load_project_context_files(&cwd, &agent);
1397 assert_eq!(files.len(), 1);
1398 assert!(files[0].content.contains('p'));
1399 let _ = fs::remove_dir_all(root);
1400 Ok(())
1401 }
1402}