1use std::collections::HashSet;
5use std::fs;
6use std::path::{Path, PathBuf};
7
8use indexmap::IndexMap;
9use oino_settings::Settings;
10use oino_settings::paths::{
11 APPEND_SYSTEM_PROMPT_FILE, SYSTEM_PROMPT_FILE, canonicalize_or_self, is_under_path,
12 lexical_normalize, project_dir, resolve_path,
13};
14use serde::{Deserialize, Serialize};
15
16use crate::context_files::{ContextFile, load_project_context_files};
17use crate::diagnostics::{CollisionResource, ResourceDiagnostic};
18use crate::discovery::{DiscoveryInput, ResolvedResource, is_local_path, resolve_local_resources};
19use crate::skills::{LoadSkillsOptions, Skill, load_skills};
20use crate::source::{PathMetadata, SourceInfo, SourceOrigin, SourceScope};
21use crate::templates::{LoadPromptTemplatesOptions, PromptTemplate, load_prompt_templates};
22use crate::walk::{has_suffix, read_dir_sorted};
23
24#[allow(
26 clippy::struct_excessive_bools,
27 reason = "mirrors the CLI flags -ns, -np, --no-themes, -nc"
28)]
29#[derive(Clone, Debug, Default, PartialEq, Eq)]
30pub struct ResourceLoaderOptions {
31 pub cwd: PathBuf,
33 pub agent_dir: PathBuf,
35 pub home_dir: Option<PathBuf>,
37 pub no_skills: bool,
39 pub no_prompt_templates: bool,
41 pub no_themes: bool,
43 pub no_context_files: bool,
45 pub skill_paths: Vec<String>,
47 pub prompt_template_paths: Vec<String>,
49 pub theme_paths: Vec<String>,
51 pub system_prompt: Option<String>,
53 pub append_system_prompt: Option<Vec<String>>,
56}
57
58#[derive(Clone, Debug, Default, PartialEq, Eq)]
61pub struct ResourceExtensionPaths {
62 pub skill_paths: Vec<ExtraResourcePath>,
64 pub prompt_paths: Vec<ExtraResourcePath>,
66 pub theme_paths: Vec<ExtraResourcePath>,
68}
69
70#[derive(Clone, Debug, PartialEq, Eq)]
72pub struct ExtraResourcePath {
73 pub path: PathBuf,
75 pub metadata: PathMetadata,
77}
78
79#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
81#[serde(rename_all = "camelCase")]
82pub struct ThemeResource {
83 pub name: String,
85 pub path: PathBuf,
87 pub source_info: SourceInfo,
89}
90
91#[derive(Clone, Debug, Default)]
94pub struct ResourceLoader {
95 options: ResourceLoaderOptions,
96 project_trusted: bool,
97 metadata: IndexMap<PathBuf, PathMetadata>,
98 extra_skill_infos: IndexMap<PathBuf, SourceInfo>,
99 extra_prompt_infos: IndexMap<PathBuf, SourceInfo>,
100 extra_theme_infos: IndexMap<PathBuf, SourceInfo>,
101 skill_paths: Vec<PathBuf>,
102 prompt_paths: Vec<PathBuf>,
103 theme_paths: Vec<PathBuf>,
104 skills: Vec<Skill>,
105 skill_diagnostics: Vec<ResourceDiagnostic>,
106 prompts: Vec<PromptTemplate>,
107 prompt_diagnostics: Vec<ResourceDiagnostic>,
108 themes: Vec<ThemeResource>,
109 theme_diagnostics: Vec<ResourceDiagnostic>,
110 context_files: Vec<ContextFile>,
111 context_diagnostics: Vec<ResourceDiagnostic>,
112 system_prompt: Option<String>,
113 system_prompt_source: Option<PathBuf>,
114 append_system_prompt: Vec<String>,
115 append_system_prompt_sources: Vec<PathBuf>,
116 prompt_input_diagnostics: Vec<ResourceDiagnostic>,
117}
118
119impl ResourceLoader {
120 #[must_use]
122 pub fn new(options: ResourceLoaderOptions) -> Self {
123 let absolute = |path: &Path| {
124 std::path::absolute(path).map_or_else(|_| path.to_path_buf(), |p| lexical_normalize(&p))
125 };
126 let options = ResourceLoaderOptions {
127 cwd: absolute(&options.cwd),
128 agent_dir: absolute(&options.agent_dir),
129 ..options
130 };
131 Self {
132 options,
133 ..Self::default()
134 }
135 }
136
137 #[must_use]
139 pub fn options(&self) -> &ResourceLoaderOptions {
140 &self.options
141 }
142
143 pub fn reload(&mut self, settings: &Settings, project_trusted: bool) {
146 self.project_trusted = project_trusted;
147 let cwd = self.options.cwd.clone();
148 let agent_dir = self.options.agent_dir.clone();
149 let home = self.options.home_dir.clone();
150 let resolved = resolve_local_resources(&DiscoveryInput {
151 cwd: &cwd,
152 agent_dir: &agent_dir,
153 home: home.as_deref(),
154 project_trusted,
155 settings,
156 });
157 self.metadata = IndexMap::new();
158 self.extra_skill_infos = IndexMap::new();
159 self.extra_prompt_infos = IndexMap::new();
160 self.extra_theme_infos = IndexMap::new();
161 let enabled_skills: Vec<PathBuf> = self
162 .enabled(&resolved.skills)
163 .into_iter()
164 .map(|resource| self.map_skill_path(&resource))
165 .collect();
166 let enabled_prompts: Vec<PathBuf> = self
167 .enabled(&resolved.prompts)
168 .into_iter()
169 .map(|r| r.path)
170 .collect();
171 let enabled_themes: Vec<PathBuf> = self
172 .enabled(&resolved.themes)
173 .into_iter()
174 .map(|r| r.path)
175 .collect();
176
177 let cli_skills = to_paths(&self.options.skill_paths);
178 let cli_prompts = to_paths(&self.options.prompt_template_paths);
179 let cli_themes = to_paths(&self.options.theme_paths);
180
181 self.skill_paths = if self.options.no_skills {
182 self.merge_paths(&[], &cli_skills)
183 } else {
184 self.merge_paths(&enabled_skills, &cli_skills)
185 };
186 self.update_skills();
187 for path in &self.options.skill_paths.clone() {
188 if is_local_path(path) {
189 let resolved = self.resolve_resource_path(Path::new(path));
190 if !resolved.exists() && !has_diagnostic_for(&self.skill_diagnostics, &resolved) {
191 self.skill_diagnostics.push(ResourceDiagnostic::error(
192 "Skill path does not exist",
193 resolved,
194 ));
195 }
196 }
197 }
198
199 self.prompt_paths = if self.options.no_prompt_templates {
200 self.merge_paths(&[], &cli_prompts)
201 } else {
202 self.merge_paths(&enabled_prompts, &cli_prompts)
203 };
204 self.update_prompts();
205 for path in &self.options.prompt_template_paths.clone() {
206 if is_local_path(path) {
207 let resolved = self.resolve_resource_path(Path::new(path));
208 if !resolved.exists() && !has_diagnostic_for(&self.prompt_diagnostics, &resolved) {
209 self.prompt_diagnostics.push(ResourceDiagnostic::error(
210 "Prompt template path does not exist",
211 resolved,
212 ));
213 }
214 }
215 }
216
217 self.theme_paths = if self.options.no_themes {
218 self.merge_paths(&[], &cli_themes)
219 } else {
220 self.merge_paths(&enabled_themes, &cli_themes)
221 };
222 self.update_themes();
223 for path in &self.options.theme_paths.clone() {
224 let resolved = self.resolve_resource_path(Path::new(path));
225 if !resolved.exists() && !has_diagnostic_for(&self.theme_diagnostics, &resolved) {
226 self.theme_diagnostics.push(ResourceDiagnostic::error(
227 "Theme path does not exist",
228 resolved,
229 ));
230 }
231 }
232
233 if self.options.no_context_files {
234 self.context_files = Vec::new();
235 self.context_diagnostics = Vec::new();
236 } else {
237 let loaded = load_project_context_files(&cwd, &agent_dir);
238 self.context_files = loaded.files;
239 self.context_diagnostics = loaded.diagnostics;
240 }
241
242 self.load_prompt_sources();
243 }
244
245 pub fn extend(&mut self, paths: &ResourceExtensionPaths) {
247 let normalize =
248 |this: &Self, entries: &[ExtraResourcePath]| -> Vec<(PathBuf, SourceInfo)> {
249 entries
250 .iter()
251 .map(|entry| {
252 let path = this.resolve_resource_path(&entry.path);
253 let mut metadata = entry.metadata.clone();
254 metadata.base_dir = metadata
255 .base_dir
256 .map(|base| this.resolve_resource_path(&base));
257 let info = SourceInfo::from_metadata(&path, &metadata);
258 (path, info)
259 })
260 .collect()
261 };
262 let skills = normalize(self, &paths.skill_paths);
263 let prompts = normalize(self, &paths.prompt_paths);
264 let themes = normalize(self, &paths.theme_paths);
265 for (path, info) in &skills {
266 self.extra_skill_infos.insert(path.clone(), info.clone());
267 }
268 for (path, info) in &prompts {
269 self.extra_prompt_infos.insert(path.clone(), info.clone());
270 }
271 for (path, info) in &themes {
272 self.extra_theme_infos.insert(path.clone(), info.clone());
273 }
274 if !skills.is_empty() {
275 let added: Vec<PathBuf> = skills.into_iter().map(|(path, _)| path).collect();
276 self.skill_paths = self.merge_paths(&self.skill_paths.clone(), &added);
277 self.update_skills();
278 }
279 if !prompts.is_empty() {
280 let added: Vec<PathBuf> = prompts.into_iter().map(|(path, _)| path).collect();
281 self.prompt_paths = self.merge_paths(&self.prompt_paths.clone(), &added);
282 self.update_prompts();
283 }
284 if !themes.is_empty() {
285 let added: Vec<PathBuf> = themes.into_iter().map(|(path, _)| path).collect();
286 self.theme_paths = self.merge_paths(&self.theme_paths.clone(), &added);
287 self.update_themes();
288 }
289 }
290
291 #[must_use]
293 pub fn skills(&self) -> &[Skill] {
294 &self.skills
295 }
296
297 #[must_use]
299 pub fn skill_diagnostics(&self) -> &[ResourceDiagnostic] {
300 &self.skill_diagnostics
301 }
302
303 #[must_use]
305 pub fn prompts(&self) -> &[PromptTemplate] {
306 &self.prompts
307 }
308
309 #[must_use]
311 pub fn prompt_diagnostics(&self) -> &[ResourceDiagnostic] {
312 &self.prompt_diagnostics
313 }
314
315 #[must_use]
317 pub fn themes(&self) -> &[ThemeResource] {
318 &self.themes
319 }
320
321 #[must_use]
323 pub fn theme_diagnostics(&self) -> &[ResourceDiagnostic] {
324 &self.theme_diagnostics
325 }
326
327 #[must_use]
329 pub fn context_files(&self) -> &[ContextFile] {
330 &self.context_files
331 }
332
333 #[must_use]
335 pub fn system_prompt(&self) -> Option<&str> {
336 self.system_prompt.as_deref()
337 }
338
339 #[must_use]
341 pub fn system_prompt_source(&self) -> Option<&Path> {
342 self.system_prompt_source.as_deref()
343 }
344
345 #[must_use]
347 pub fn append_system_prompt(&self) -> &[String] {
348 &self.append_system_prompt
349 }
350
351 #[must_use]
353 pub fn addendum(&self) -> String {
354 self.append_system_prompt.join("\n\n")
355 }
356
357 #[must_use]
359 pub fn append_system_prompt_sources(&self) -> &[PathBuf] {
360 &self.append_system_prompt_sources
361 }
362
363 #[must_use]
365 pub fn diagnostics(&self) -> Vec<ResourceDiagnostic> {
366 [
367 &self.skill_diagnostics,
368 &self.prompt_diagnostics,
369 &self.theme_diagnostics,
370 &self.context_diagnostics,
371 &self.prompt_input_diagnostics,
372 ]
373 .into_iter()
374 .flatten()
375 .cloned()
376 .collect()
377 }
378
379 fn enabled(&mut self, resources: &[ResolvedResource]) -> Vec<ResolvedResource> {
380 for resource in resources {
381 self.metadata
382 .entry(resource.path.clone())
383 .or_insert_with(|| resource.metadata.clone());
384 }
385 resources.iter().filter(|r| r.enabled).cloned().collect()
386 }
387
388 fn map_skill_path(&mut self, resource: &ResolvedResource) -> PathBuf {
390 if resource.metadata.source != "auto" && resource.metadata.origin != SourceOrigin::Package {
391 return resource.path.clone();
392 }
393 if !resource.path.is_dir() {
394 return resource.path.clone();
395 }
396 let skill_file = resource.path.join("SKILL.md");
397 if skill_file.exists() {
398 self.metadata
399 .entry(skill_file.clone())
400 .or_insert_with(|| resource.metadata.clone());
401 return skill_file;
402 }
403 resource.path.clone()
404 }
405
406 fn resolve_resource_path(&self, path: &Path) -> PathBuf {
407 resolve_path(
408 &path.to_string_lossy(),
409 &self.options.cwd,
410 self.options.home_dir.as_deref(),
411 true,
412 )
413 }
414
415 fn merge_paths(&self, primary: &[PathBuf], additional: &[PathBuf]) -> Vec<PathBuf> {
417 let mut seen = HashSet::new();
418 primary
419 .iter()
420 .chain(additional)
421 .map(|path| self.resolve_resource_path(path))
422 .filter(|path| seen.insert(canonicalize_or_self(path)))
423 .collect()
424 }
425
426 fn update_skills(&mut self) {
427 if self.options.no_skills && self.skill_paths.is_empty() {
428 self.skills = Vec::new();
429 self.skill_diagnostics = Vec::new();
430 return;
431 }
432 let result = load_skills(&LoadSkillsOptions {
433 cwd: &self.options.cwd,
434 agent_dir: &self.options.agent_dir,
435 home: self.options.home_dir.as_deref(),
436 skill_paths: &self.skill_paths,
437 include_defaults: false,
438 });
439 self.skills = result
440 .skills
441 .into_iter()
442 .map(|mut skill| {
443 if let Some(info) = self.find_source_info(&skill.file_path, &self.extra_skill_infos)
444 {
445 skill.source_info = info;
446 }
447 skill
448 })
449 .collect();
450 self.skill_diagnostics = result.diagnostics;
451 }
452
453 fn update_prompts(&mut self) {
454 if self.options.no_prompt_templates && self.prompt_paths.is_empty() {
455 self.prompts = Vec::new();
456 self.prompt_diagnostics = Vec::new();
457 return;
458 }
459 let loaded = load_prompt_templates(&LoadPromptTemplatesOptions {
460 cwd: &self.options.cwd,
461 agent_dir: &self.options.agent_dir,
462 home: self.options.home_dir.as_deref(),
463 prompt_paths: &self.prompt_paths,
464 include_defaults: false,
465 });
466 let mut diagnostics = loaded.diagnostics;
467 let mut by_name: IndexMap<String, PromptTemplate> = IndexMap::new();
468 for prompt in loaded.templates {
469 if let Some(existing) = by_name.get(&prompt.name) {
470 diagnostics.push(ResourceDiagnostic::collision(
471 format!("name \"/{}\" collision", prompt.name),
472 CollisionResource::Prompt,
473 &prompt.name,
474 &existing.file_path,
475 &prompt.file_path,
476 ));
477 } else {
478 by_name.insert(prompt.name.clone(), prompt);
479 }
480 }
481 self.prompts = by_name
482 .into_values()
483 .map(|mut prompt| {
484 if let Some(info) =
485 self.find_source_info(&prompt.file_path, &self.extra_prompt_infos)
486 {
487 prompt.source_info = info;
488 }
489 prompt
490 })
491 .collect();
492 self.prompt_diagnostics = diagnostics;
493 }
494
495 fn update_themes(&mut self) {
496 if self.options.no_themes && self.theme_paths.is_empty() {
497 self.themes = Vec::new();
498 self.theme_diagnostics = Vec::new();
499 return;
500 }
501 let mut files = Vec::new();
502 let mut diagnostics = Vec::new();
503 for path in &self.theme_paths {
504 let Ok(metadata) = fs::metadata(path) else {
505 diagnostics.push(ResourceDiagnostic::warning(
506 "theme path does not exist",
507 path,
508 ));
509 continue;
510 };
511 if metadata.is_dir() {
512 for entry in read_dir_sorted(path).unwrap_or_default() {
513 if entry.is_file && has_suffix(&entry.name, ".json") {
514 files.push(entry.path);
515 }
516 }
517 } else if metadata.is_file() && has_suffix(&path.to_string_lossy(), ".json") {
518 files.push(path.clone());
519 } else {
520 diagnostics.push(ResourceDiagnostic::warning(
521 "theme path is not a json file",
522 path,
523 ));
524 }
525 }
526 let mut by_name: IndexMap<String, ThemeResource> = IndexMap::new();
527 for file in files {
528 let name = match theme_name(&file) {
529 Ok(name) => name,
530 Err(message) => {
531 diagnostics.push(ResourceDiagnostic::warning(message, &file));
532 continue;
533 }
534 };
535 if let Some(existing) = by_name.get(&name) {
536 diagnostics.push(ResourceDiagnostic::collision(
537 format!("name \"{name}\" collision"),
538 CollisionResource::Theme,
539 &name,
540 &existing.path,
541 &file,
542 ));
543 continue;
544 }
545 let source_info = self
546 .find_source_info(&file, &self.extra_theme_infos)
547 .unwrap_or_else(|| self.default_source_info(&file));
548 by_name.insert(
549 name.clone(),
550 ThemeResource {
551 name,
552 path: file,
553 source_info,
554 },
555 );
556 }
557 self.themes = by_name.into_values().collect();
558 self.theme_diagnostics = diagnostics;
559 }
560
561 fn find_source_info(
563 &self,
564 path: &Path,
565 extra: &IndexMap<PathBuf, SourceInfo>,
566 ) -> Option<SourceInfo> {
567 let normalized = lexical_normalize(path);
568 if let Some(info) = extra
569 .iter()
570 .find(|(source, _)| normalized.starts_with(lexical_normalize(source)))
571 .map(|(_, info)| info)
572 {
573 return Some(SourceInfo {
574 path: path.to_path_buf(),
575 ..info.clone()
576 });
577 }
578 if let Some(metadata) = self
579 .metadata
580 .get(&normalized)
581 .or_else(|| self.metadata.get(path))
582 {
583 return Some(SourceInfo::from_metadata(path, metadata));
584 }
585 self.metadata
586 .iter()
587 .find(|(source, _)| normalized.starts_with(lexical_normalize(source)))
588 .map(|(_, metadata)| SourceInfo::from_metadata(path, metadata))
589 }
590
591 fn default_source_info(&self, path: &Path) -> SourceInfo {
593 let normalized = lexical_normalize(path);
594 let kinds = ["skills", "prompts", "themes", "extensions"];
595 let project = project_dir(&self.options.cwd);
596 for (base, scope) in [
597 (&self.options.agent_dir, SourceScope::User),
598 (&project, SourceScope::Project),
599 ] {
600 if let Some(root) = kinds
601 .iter()
602 .map(|kind| base.join(kind))
603 .find(|root| is_under_path(&normalized, root))
604 {
605 return SourceInfo::synthetic(path, "local", Some(scope), Some(root));
606 }
607 }
608 let base = if normalized.is_dir() {
609 normalized.clone()
610 } else {
611 normalized
612 .parent()
613 .map_or_else(|| normalized.clone(), Path::to_path_buf)
614 };
615 SourceInfo::synthetic(path, "local", None, Some(base))
616 }
617
618 fn load_prompt_sources(&mut self) {
619 self.prompt_input_diagnostics = Vec::new();
620 let project = project_dir(&self.options.cwd);
621 let discover = |name: &str, trusted: bool, agent_dir: &Path| -> Option<String> {
622 let project_file = project.join(name);
623 if trusted && project_file.exists() {
624 return Some(project_file.to_string_lossy().into_owned());
625 }
626 let global = agent_dir.join(name);
627 global
628 .exists()
629 .then(|| global.to_string_lossy().into_owned())
630 };
631 let source = self.options.system_prompt.clone().or_else(|| {
632 discover(
633 SYSTEM_PROMPT_FILE,
634 self.project_trusted,
635 &self.options.agent_dir,
636 )
637 });
638 self.system_prompt = source
639 .as_deref()
640 .and_then(|input| self.resolve_prompt_input(input, "system prompt"));
641 self.system_prompt_source = source
642 .as_deref()
643 .and_then(|input| self.existing_path(input));
644
645 let append_sources = self
646 .options
647 .append_system_prompt
648 .clone()
649 .unwrap_or_else(|| {
650 discover(
651 APPEND_SYSTEM_PROMPT_FILE,
652 self.project_trusted,
653 &self.options.agent_dir,
654 )
655 .into_iter()
656 .collect()
657 });
658 self.append_system_prompt = append_sources
659 .iter()
660 .filter_map(|input| self.resolve_prompt_input(input, "append system prompt"))
661 .collect();
662 self.append_system_prompt_sources = append_sources
663 .iter()
664 .filter_map(|input| self.existing_path(input))
665 .collect();
666 }
667
668 fn existing_path(&self, input: &str) -> Option<PathBuf> {
669 let path = resolve_path(input, &self.options.cwd, None, false);
670 path.exists().then_some(path)
671 }
672
673 fn resolve_prompt_input(&mut self, input: &str, description: &str) -> Option<String> {
675 if input.is_empty() {
676 return None;
677 }
678 let Some(path) = self.existing_path(input) else {
679 return Some(input.to_owned());
680 };
681 match fs::read_to_string(&path) {
682 Ok(content) => Some(
683 content
684 .strip_prefix('\u{feff}')
685 .map_or(content.clone(), str::to_owned),
686 ),
687 Err(error) => {
688 self.prompt_input_diagnostics
689 .push(ResourceDiagnostic::warning(
690 format!("Could not read {description} file {input}: {error}"),
691 &path,
692 ));
693 Some(input.to_owned())
694 }
695 }
696 }
697}
698
699fn to_paths(values: &[String]) -> Vec<PathBuf> {
700 values.iter().map(PathBuf::from).collect()
701}
702
703fn has_diagnostic_for(diagnostics: &[ResourceDiagnostic], path: &Path) -> bool {
704 diagnostics
705 .iter()
706 .any(|diagnostic| diagnostic.path.as_deref() == Some(path))
707}
708
709fn theme_name(path: &Path) -> Result<String, String> {
711 let content = fs::read_to_string(path).map_err(|e| e.to_string())?;
712 let value: serde_json::Value = serde_json::from_str(oino_settings::json::strip_bom(&content))
713 .map_err(|e| e.to_string())?;
714 let object = value
715 .as_object()
716 .ok_or_else(|| "theme must be a JSON object".to_owned())?;
717 Ok(object
718 .get("name")
719 .and_then(serde_json::Value::as_str)
720 .unwrap_or("unnamed")
721 .to_owned())
722}