Skip to main content

pi/core/resources/
mod.rs

1//! Product resource discovery, loading, and snapshot management.
2//!
3//! Ports:
4//! - `package-manager.ts` resolve-side → [`discovery`]
5//! - `skills.ts` → [`skills`]
6//! - `prompt-templates.ts` → [`prompts`]
7//! - theme path collection from `resource-loader.ts` → [`themes`]
8//! - `resource-loader.ts` → [`ResourceLoader`] / [`DefaultResourceLoader`]
9
10pub 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/// Extension path info for Phase 3 (paths only; no execution).
67#[derive(Clone, Debug, Eq, PartialEq)]
68pub struct ExtensionPathInfo {
69    /// Configured / discovered path.
70    pub path: String,
71    /// Resolved absolute path when available.
72    pub resolved_path: String,
73    /// Provenance.
74    pub source_info: SourceInfo,
75}
76
77/// Extension load result for Phase 3: paths + missing-path errors only.
78#[derive(Clone, Debug, Default, PartialEq)]
79pub struct LoadExtensionsResult {
80    /// Extension paths (not executed in Phase 3).
81    pub paths: Vec<ExtensionPathInfo>,
82    /// Path-level errors (`{path, error}`).
83    pub errors: Vec<ExtensionLoadError>,
84}
85
86/// Single extension path error.
87#[derive(Clone, Debug, Eq, PartialEq)]
88pub struct ExtensionLoadError {
89    /// Path that failed.
90    pub path: String,
91    /// Error message.
92    pub error: String,
93}
94
95/// Context file (`AGENTS.md` / `CLAUDE.md`) content.
96#[derive(Clone, Debug, Eq, PartialEq)]
97pub struct AgentsFile {
98    /// Absolute path.
99    pub path: String,
100    /// File contents.
101    pub content: String,
102}
103
104/// Paths registered via [`ResourceLoader::extend_resources`].
105#[derive(Clone, Debug, Default, Eq, PartialEq)]
106pub struct ResourceExtensionPaths {
107    /// Extra skill paths with metadata.
108    pub skill_paths: Vec<ExtensionResourcePath>,
109    /// Extra prompt paths with metadata.
110    pub prompt_paths: Vec<ExtensionResourcePath>,
111    /// Extra theme paths with metadata.
112    pub theme_paths: Vec<ExtensionResourcePath>,
113}
114
115impl ResourceExtensionPaths {
116    /// Whether discovery produced no skill, prompt, or theme paths.
117    #[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/// One extension-provided resource path.
124#[derive(Clone, Debug, Eq, PartialEq)]
125pub struct ExtensionResourcePath {
126    /// Resource path.
127    pub path: String,
128    /// Discovery metadata.
129    pub metadata: PathMetadata,
130}
131
132impl ExtensionResourcePath {
133    /// Build temporary provenance for a path returned by one extension.
134    #[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
162/// Source-compatible disabled flag used by resource-loader construction options.
163///
164/// Loader construction immediately normalizes these flags into one discovery policy.
165pub type ResourceLoadingDisabled = bool;
166
167/// Options for [`DefaultResourceLoader::new`].
168pub struct DefaultResourceLoaderOptions {
169    /// Working directory.
170    pub cwd: PathBuf,
171    /// Agent config directory.
172    pub agent_dir: PathBuf,
173    /// Optional settings manager (created when absent).
174    pub settings_manager: Option<SettingsManager>,
175    /// Additional CLI extension sources.
176    pub additional_extension_paths: Vec<String>,
177    /// Additional CLI skill paths.
178    pub additional_skill_paths: Vec<String>,
179    /// Additional CLI prompt template paths.
180    pub additional_prompt_template_paths: Vec<String>,
181    /// Additional CLI theme paths.
182    pub additional_theme_paths: Vec<String>,
183    /// Skip package/settings extensions (CLI still applied).
184    pub no_extensions: ResourceLoadingDisabled,
185    /// Skip package/settings skills (CLI still applied).
186    pub no_skills: ResourceLoadingDisabled,
187    /// Skip package/settings prompts (CLI still applied).
188    pub no_prompt_templates: ResourceLoadingDisabled,
189    /// Skip package/settings themes (CLI still applied).
190    pub no_themes: ResourceLoadingDisabled,
191    /// Skip AGENTS/CLAUDE context files.
192    pub no_context_files: ResourceLoadingDisabled,
193    /// Explicit system prompt (file path or literal).
194    pub system_prompt: Option<String>,
195    /// Explicit append system prompts (file paths or literals).
196    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/// Resource loader errors.
221#[derive(Debug, Error)]
222pub enum ResourceLoaderError {
223    /// Package path resolution failed.
224    #[error(transparent)]
225    Package(#[from] PackageResolveError),
226    /// Blocking task join failed.
227    #[error("resource resolve task failed: {0}")]
228    Join(String),
229}
230
231/// Resource loading surface matching TypeScript `ResourceLoader`.
232pub trait ResourceLoader {
233    /// Extension paths (Phase 3: no execution).
234    fn get_extensions(&self) -> &LoadExtensionsResult;
235    /// Skills + diagnostics.
236    fn get_skills(&self) -> (&[Skill], &[ResourceDiagnostic]);
237    /// Prompts + diagnostics.
238    fn get_prompts(&self) -> (&[PromptTemplate], &[ResourceDiagnostic]);
239    /// Themes + diagnostics.
240    fn get_themes(&self) -> (&[LoadedTheme], &[ResourceDiagnostic]);
241    /// Context files.
242    fn get_agents_files(&self) -> &[AgentsFile];
243    /// Resolved system prompt text, if any.
244    fn get_system_prompt(&self) -> Option<&str>;
245    /// Resolved append-system-prompt texts.
246    fn get_append_system_prompt(&self) -> &[String];
247    /// Extend skill/prompt/theme paths after load.
248    fn extend_resources(&mut self, paths: ResourceExtensionPaths);
249    /// Reload settings, packages, and all resource snapshots.
250    ///
251    /// # Errors
252    ///
253    /// Returns [`ResourceLoaderError`] when package resolve fails or the
254    /// blocking resolve task panics.
255    fn reload(&mut self) -> impl Future<Output = Result<(), ResourceLoaderError>>;
256}
257/// Immutable resource snapshot replaced on reload.
258#[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
320/// Default resource loader with immutable snapshot replaced on reload.
321pub 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    /// Create a loader (does not load until [`ResourceLoader::reload`]).
347    #[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    /// Mutable access to the settings manager.
393    pub fn settings_manager_mut(&mut self) -> &mut SettingsManager {
394        &mut self.settings_manager
395    }
396
397    /// Shared settings manager reference.
398    #[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/// Load AGENTS/CLAUDE context files: global agentDir first, then root→cwd.
1019///
1020/// Not trust-gated (matches TypeScript `loadProjectContextFiles`).
1021#[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(&current)
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    // Match TS: prefix is root + separator — never `/skills-extra` for `/skills`.
1147    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}