Skip to main content

opi_coding_agent/
package_discovery.rs

1//! Package progressive discovery, resource composition, and registry.
2//!
3//! Provides the discovery and registry system for packages that compose
4//! extensions, skills, prompt fragments, and themes through validated manifests
5//! or conventional directories. Package metadata (name, description, version)
6//! is available without eagerly loading any contained resource content.
7//!
8//! # Package Format
9//!
10//! Each package is a directory containing a `package.toml` manifest and optional
11//! resource subdirectories:
12//!
13//! ```text
14//! my-package/
15//!   package.toml
16//!   extensions/
17//!     my-ext/
18//!       extension.toml
19//!   skills/
20//!     my-skill/
21//!       SKILL.md
22//!   fragments/
23//!     my-frag/
24//!       FRAGMENT.md
25//!   themes/
26//!     my-theme/
27//!       theme.toml
28//! ```
29//!
30//! # package.toml Format
31//!
32//! ```toml
33//! name = "my-package"
34//! description = "A collection of productivity tools."
35//! version = "1.0.0"               # optional
36//!
37//! # Optional: explicit resource allowlists (absent = auto-discover all)
38//! extensions = ["my-ext"]
39//! skills = ["my-skill"]
40//! fragments = ["my-frag"]
41//! themes = ["my-theme"]
42//!
43//! # Optional: resources to exclude by name (matched across all types)
44//! disabled = ["deprecated-skill"]
45//! ```
46//!
47//! # Validated Manifests vs Conventional Directories
48//!
49//! When resource lists (`extensions`, `skills`, `fragments`, `themes`) are
50//! present, the package uses **validated manifest** mode: only listed
51//! resources are included, and all listed resources must exist (missing assets
52//! produce errors).
53//!
54//! When resource lists are absent, the package uses **conventional directory**
55//! mode: all valid resources found in the subdirectories are included.
56//!
57//! # Discovery Precedence
58//!
59//! Packages use the same precedence-based discovery as extensions and skills
60//! (see [`crate::resource`]). Higher precedence values override lower ones
61//! when package names collide.
62//!
63//! # Security
64//!
65//! Resource paths are validated to stay within the package directory. Path
66//! traversal attempts (via symlinks or `..` components) produce security
67//! diagnostic errors.
68//!
69//! # Unstable
70//!
71//! This module is part of the **unstable 0.x extension API**. Breaking changes
72//! may occur between minor versions without a major version bump.
73
74use std::collections::HashSet;
75use std::path::{Path, PathBuf};
76
77use serde::Deserialize;
78
79// ---------------------------------------------------------------------------
80// Error types
81// ---------------------------------------------------------------------------
82
83/// Errors from package discovery, manifest parsing, and resource composition.
84#[derive(Debug, thiserror::Error)]
85pub enum PackageDiscoveryError {
86    /// The package.toml file could not be parsed as valid TOML.
87    #[error("invalid package manifest at {path}: {reason}")]
88    InvalidManifest { path: PathBuf, reason: String },
89    /// A required field is missing or empty in the manifest.
90    #[error("missing required field '{field}' in package at {path}")]
91    MissingField { field: String, path: PathBuf },
92    /// Two packages in the same precedence layer use the same name.
93    #[error("duplicate package name '{name}' in discovery layer at {path}")]
94    DuplicateName { name: String, path: PathBuf },
95    /// The package name is invalid (bad characters or too long).
96    #[error("invalid package name in {path}: {reason}")]
97    InvalidName { path: PathBuf, reason: String },
98    /// The description is invalid (too long).
99    #[error("invalid description in package at {path}: {reason}")]
100    InvalidDescription { path: PathBuf, reason: String },
101    /// A resource listed in the include list was not found.
102    #[error("missing {kind} '{name}' in package '{package_name}'")]
103    MissingAsset {
104        package_name: String,
105        kind: String,
106        name: String,
107    },
108    /// A resource path escapes the package directory.
109    #[error(
110        "security: resource path escapes package directory for {package_name}: {path} ({reason})"
111    )]
112    SecurityDiagnostic {
113        package_name: String,
114        path: PathBuf,
115        reason: String,
116    },
117    /// An I/O error occurred during discovery or composition.
118    #[error("I/O error discovering packages: {0}")]
119    Io(#[from] std::io::Error),
120}
121
122// ---------------------------------------------------------------------------
123// Constants
124// ---------------------------------------------------------------------------
125
126/// Maximum allowed length for a package name.
127const MAX_NAME_LEN: usize = 64;
128
129/// Maximum allowed length for a package description.
130const MAX_DESCRIPTION_LEN: usize = 1024;
131
132// ---------------------------------------------------------------------------
133// TOML deserialization
134// ---------------------------------------------------------------------------
135
136/// Top-level TOML structure for package files.
137#[derive(Debug, Clone, Deserialize)]
138struct TomlPackageFile {
139    name: Option<String>,
140    description: Option<String>,
141    version: Option<String>,
142    extensions: Option<Vec<String>>,
143    skills: Option<Vec<String>>,
144    fragments: Option<Vec<String>>,
145    themes: Option<Vec<String>>,
146    disabled: Option<Vec<String>>,
147}
148
149// ---------------------------------------------------------------------------
150// Manifest types
151// ---------------------------------------------------------------------------
152
153/// Parsed package manifest from `package.toml`.
154#[derive(Debug, Clone, PartialEq)]
155pub struct PackageManifest {
156    /// Package name. Required, non-empty. Lowercase ASCII letters, digits,
157    /// and hyphens. Maximum 64 characters.
158    pub name: String,
159    /// Human-readable description. Required, non-empty. Maximum 1024
160    /// characters.
161    pub description: String,
162    /// Semantic version string. Optional.
163    pub version: Option<String>,
164    /// Explicit list of extension names to include. When `None`, all
165    /// discovered extensions in the `extensions/` subdirectory are included.
166    pub extensions: Option<Vec<String>>,
167    /// Explicit list of skill names to include. When `None`, all discovered
168    /// skills in the `skills/` subdirectory are included.
169    pub skills: Option<Vec<String>>,
170    /// Explicit list of fragment names to include. When `None`, all discovered
171    /// fragments in the `fragments/` subdirectory are included.
172    pub fragments: Option<Vec<String>>,
173    /// Explicit list of theme names to include. When `None`, all discovered
174    /// themes in the `themes/` subdirectory are included.
175    pub themes: Option<Vec<String>>,
176    /// Resource names to exclude from composition, regardless of type.
177    pub disabled: Vec<String>,
178}
179
180impl PackageManifest {
181    /// Parse a manifest from TOML content, validating required fields.
182    pub fn from_toml(content: &str, path: &Path) -> Result<Self, PackageDiscoveryError> {
183        let file: TomlPackageFile =
184            toml::from_str(content).map_err(|e| PackageDiscoveryError::InvalidManifest {
185                path: path.to_path_buf(),
186                reason: e.to_string(),
187            })?;
188
189        let name = file.name.filter(|n| !n.trim().is_empty()).ok_or_else(|| {
190            PackageDiscoveryError::MissingField {
191                field: "name".into(),
192                path: path.to_path_buf(),
193            }
194        })?;
195
196        validate_package_name(&name, path)?;
197
198        let description = file
199            .description
200            .filter(|d| !d.trim().is_empty())
201            .ok_or_else(|| PackageDiscoveryError::MissingField {
202                field: "description".into(),
203                path: path.to_path_buf(),
204            })?;
205
206        validate_description(&description, path)?;
207
208        Ok(Self {
209            name,
210            description,
211            version: file.version,
212            extensions: file.extensions,
213            skills: file.skills,
214            fragments: file.fragments,
215            themes: file.themes,
216            disabled: file.disabled.unwrap_or_default(),
217        })
218    }
219}
220
221// ---------------------------------------------------------------------------
222// Resource kinds
223// ---------------------------------------------------------------------------
224
225/// The kind of a resource within a package.
226#[derive(Debug, Clone, Copy, PartialEq, Eq)]
227pub enum ResourceKind {
228    /// Extension resource.
229    Extension,
230    /// Skill resource.
231    Skill,
232    /// Prompt fragment resource.
233    Fragment,
234    /// Theme resource.
235    Theme,
236}
237
238impl std::fmt::Display for ResourceKind {
239    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
240        match self {
241            Self::Extension => write!(f, "extension"),
242            Self::Skill => write!(f, "skill"),
243            Self::Fragment => write!(f, "fragment"),
244            Self::Theme => write!(f, "theme"),
245        }
246    }
247}
248
249/// Resource type metadata: subdirectory name and marker file.
250struct ResourceTypeSpec {
251    kind: ResourceKind,
252    subdir: &'static str,
253    marker: &'static str,
254}
255
256const RESOURCE_TYPES: &[ResourceTypeSpec] = &[
257    ResourceTypeSpec {
258        kind: ResourceKind::Extension,
259        subdir: "extensions",
260        marker: "extension.toml",
261    },
262    ResourceTypeSpec {
263        kind: ResourceKind::Skill,
264        subdir: "skills",
265        marker: "SKILL.md",
266    },
267    ResourceTypeSpec {
268        kind: ResourceKind::Fragment,
269        subdir: "fragments",
270        marker: "FRAGMENT.md",
271    },
272    ResourceTypeSpec {
273        kind: ResourceKind::Theme,
274        subdir: "themes",
275        marker: "theme.toml",
276    },
277];
278
279// ---------------------------------------------------------------------------
280// Composed resource
281// ---------------------------------------------------------------------------
282
283/// A discovered resource within a package.
284///
285/// Contains the resource kind, directory name, and path. Does not hold parsed
286/// resource manifests — those are loaded on demand by the respective discovery
287/// modules when needed.
288#[derive(Debug, Clone)]
289pub struct ComposedResource {
290    /// The kind of resource.
291    pub kind: ResourceKind,
292    /// Resource directory name (used as identifier).
293    pub name: String,
294    /// Absolute path to the resource directory.
295    pub path: PathBuf,
296}
297
298/// Discovery layers produced by composing package-contained resources.
299#[derive(Debug, Clone, Default)]
300pub struct PackageComposedResourceLayers {
301    pub extensions: Vec<crate::resource::DiscoveryLayer>,
302    pub skills: Vec<crate::resource::DiscoveryLayer>,
303    pub fragments: Vec<crate::resource::DiscoveryLayer>,
304    pub themes: Vec<crate::resource::DiscoveryLayer>,
305    pub diagnostics: Vec<String>,
306}
307
308// ---------------------------------------------------------------------------
309// Package resource
310// ---------------------------------------------------------------------------
311
312/// A discovered package resource with its manifest, filesystem path, and layer
313/// precedence.
314///
315/// The manifest metadata is available immediately. Resource composition is
316/// performed on demand via [`compose`](PackageResource::compose).
317#[derive(Debug, Clone)]
318pub struct PackageResource {
319    /// The parsed package manifest (metadata only).
320    pub manifest: PackageManifest,
321    /// Absolute path to the package directory.
322    pub path: PathBuf,
323    /// Path to the `package.toml` file for reference.
324    pub package_toml_path: PathBuf,
325    /// Precedence value of the discovery layer that produced this resource.
326    pub layer_precedence: u32,
327}
328
329impl PackageResource {
330    /// Compose all resources from this package, applying filtering and security
331    /// checks.
332    ///
333    /// Scans the `extensions/`, `skills/`, `fragments/`, and `themes/`
334    /// subdirectories. When include lists are present in the manifest, only
335    /// listed resources are included and all must be found. Resources in the
336    /// `disabled` list are excluded. Resource paths are validated to stay
337    /// within the package directory.
338    pub fn compose(&self) -> Result<Vec<ComposedResource>, PackageDiscoveryError> {
339        let mut resources = Vec::new();
340        let disabled: HashSet<&str> = self.manifest.disabled.iter().map(|s| s.as_str()).collect();
341
342        let include_lists: [(ResourceKind, &Option<Vec<String>>); 4] = [
343            (ResourceKind::Extension, &self.manifest.extensions),
344            (ResourceKind::Skill, &self.manifest.skills),
345            (ResourceKind::Fragment, &self.manifest.fragments),
346            (ResourceKind::Theme, &self.manifest.themes),
347        ];
348
349        for spec in RESOURCE_TYPES {
350            let include_list = include_lists
351                .iter()
352                .find(|(k, _)| *k == spec.kind)
353                .map(|(_, l)| *l)
354                .unwrap_or(&None);
355
356            self.compose_type(spec, include_list, &disabled, &mut resources)?;
357        }
358
359        Ok(resources)
360    }
361
362    /// Compose resources of a single type.
363    fn compose_type(
364        &self,
365        spec: &ResourceTypeSpec,
366        include_list: &Option<Vec<String>>,
367        disabled: &HashSet<&str>,
368        resources: &mut Vec<ComposedResource>,
369    ) -> Result<(), PackageDiscoveryError> {
370        let type_dir = self.path.join(spec.subdir);
371
372        if !type_dir.is_dir() {
373            // If include list exists, all entries must be found
374            if let Some(includes) = include_list {
375                for name in includes {
376                    if !disabled.contains(name.as_str()) {
377                        return Err(PackageDiscoveryError::MissingAsset {
378                            package_name: self.manifest.name.clone(),
379                            kind: spec.kind.to_string(),
380                            name: name.clone(),
381                        });
382                    }
383                }
384            }
385            return Ok(());
386        }
387
388        let canonical_package = self.path.canonicalize()?;
389
390        if let Some(includes) = include_list {
391            // Validated manifest mode: only include listed resources
392            for name in includes {
393                if disabled.contains(name.as_str()) {
394                    continue;
395                }
396
397                let resource_dir = type_dir.join(name);
398                if !resource_dir.is_dir() {
399                    return Err(PackageDiscoveryError::MissingAsset {
400                        package_name: self.manifest.name.clone(),
401                        kind: spec.kind.to_string(),
402                        name: name.clone(),
403                    });
404                }
405
406                let marker = resource_dir.join(spec.marker);
407                if !marker.exists() {
408                    return Err(PackageDiscoveryError::MissingAsset {
409                        package_name: self.manifest.name.clone(),
410                        kind: spec.kind.to_string(),
411                        name: name.clone(),
412                    });
413                }
414
415                // Security check
416                let canonical_resource = resource_dir.canonicalize()?;
417                if !canonical_resource.starts_with(&canonical_package) {
418                    return Err(PackageDiscoveryError::SecurityDiagnostic {
419                        package_name: self.manifest.name.clone(),
420                        path: canonical_resource,
421                        reason: format!(
422                            "resource path escapes package directory for {} '{}'",
423                            spec.kind, name
424                        ),
425                    });
426                }
427
428                resources.push(ComposedResource {
429                    kind: spec.kind,
430                    name: name.clone(),
431                    path: resource_dir,
432                });
433            }
434        } else {
435            // Conventional directory mode: auto-discover all valid resources
436            let entries = std::fs::read_dir(&type_dir)?;
437            for entry in entries {
438                let entry = entry?;
439                let resource_dir = entry.path();
440
441                if !resource_dir.is_dir() {
442                    continue;
443                }
444
445                let resource_name = match resource_dir.file_name().and_then(|n| n.to_str()) {
446                    Some(n) => n.to_string(),
447                    None => continue,
448                };
449
450                // Skip disabled resources
451                if disabled.contains(resource_name.as_str()) {
452                    continue;
453                }
454
455                // Check marker file exists
456                let marker = resource_dir.join(spec.marker);
457                if !marker.exists() {
458                    continue;
459                }
460
461                // Security check
462                let canonical_resource = resource_dir.canonicalize()?;
463                if !canonical_resource.starts_with(&canonical_package) {
464                    return Err(PackageDiscoveryError::SecurityDiagnostic {
465                        package_name: self.manifest.name.clone(),
466                        path: canonical_resource,
467                        reason: format!(
468                            "resource path escapes package directory for {} '{}'",
469                            spec.kind, resource_name
470                        ),
471                    });
472                }
473
474                resources.push(ComposedResource {
475                    kind: spec.kind,
476                    name: resource_name,
477                    path: resource_dir,
478                });
479            }
480        }
481
482        Ok(())
483    }
484}
485
486// ---------------------------------------------------------------------------
487// Discovery
488// ---------------------------------------------------------------------------
489
490/// Discover packages across multiple layers with precedence-based
491/// deduplication.
492///
493/// Each layer's scan directory is enumerated for subdirectories containing
494/// `package.toml` files. When multiple layers produce packages with the same
495/// name, the one with the highest `precedence` value is kept. Duplicate names
496/// within the same precedence layer are reported as an error.
497///
498/// Returns the deduplicated list of discovered package resources, sorted by
499/// name. Missing scan directories are silently skipped.
500pub fn discover_packages(
501    layers: &[crate::resource::DiscoveryLayer],
502) -> Result<Vec<PackageResource>, PackageDiscoveryError> {
503    let mut seen: std::collections::HashMap<String, PackageResource> =
504        std::collections::HashMap::new();
505
506    for layer in layers {
507        let scan_dir = layer.scan_dir();
508        if !scan_dir.is_dir() {
509            continue;
510        }
511
512        if scan_dir.join("package.toml").exists() {
513            discover_package_dir(&scan_dir, layer, &mut seen)?;
514            continue;
515        }
516
517        let entries = match std::fs::read_dir(&scan_dir) {
518            Ok(entries) => entries,
519            Err(e) => return Err(PackageDiscoveryError::Io(e)),
520        };
521
522        for entry in entries {
523            let entry = entry?;
524            let path = entry.path();
525
526            if !path.is_dir() {
527                continue;
528            }
529
530            let pkg_toml = path.join("package.toml");
531            if !pkg_toml.exists() {
532                continue;
533            }
534
535            discover_package_dir(&path, layer, &mut seen)?;
536        }
537    }
538
539    let mut resources: Vec<PackageResource> = seen.into_values().collect();
540    resources.sort_by(|a, b| a.manifest.name.cmp(&b.manifest.name));
541    Ok(resources)
542}
543
544fn discover_package_dir(
545    path: &Path,
546    layer: &crate::resource::DiscoveryLayer,
547    seen: &mut std::collections::HashMap<String, PackageResource>,
548) -> Result<(), PackageDiscoveryError> {
549    let pkg_toml = path.join("package.toml");
550    let content = std::fs::read_to_string(&pkg_toml)?;
551    let manifest = PackageManifest::from_toml(&content, &pkg_toml)?;
552
553    let canonical = path.canonicalize()?;
554
555    match seen.get(&manifest.name) {
556        Some(existing) if layer.precedence == existing.layer_precedence => {
557            return Err(PackageDiscoveryError::DuplicateName {
558                name: manifest.name,
559                path: canonical,
560            });
561        }
562        Some(existing) if layer.precedence < existing.layer_precedence => return Ok(()),
563        Some(_) | None => {
564            seen.insert(
565                manifest.name.clone(),
566                PackageResource {
567                    manifest,
568                    path: canonical,
569                    package_toml_path: pkg_toml,
570                    layer_precedence: layer.precedence,
571                },
572            );
573        }
574    }
575
576    Ok(())
577}
578
579/// Compose package resources into direct discovery layers grouped by kind.
580///
581/// Composition diagnostics are collected instead of panicking so production
582/// harness construction can surface them in metadata.
583pub fn package_composed_resource_layers(
584    packages: &[PackageResource],
585) -> PackageComposedResourceLayers {
586    let mut result = PackageComposedResourceLayers::default();
587    let mut ordered: Vec<&PackageResource> = packages.iter().collect();
588    ordered.sort_by(|a, b| {
589        a.layer_precedence
590            .cmp(&b.layer_precedence)
591            .then_with(|| a.manifest.name.cmp(&b.manifest.name))
592    });
593
594    for package in ordered {
595        let mut resources = match package.compose() {
596            Ok(resources) => resources,
597            Err(e) => {
598                result
599                    .diagnostics
600                    .push(format!("package '{}': {e}", package.manifest.name));
601                continue;
602            }
603        };
604        resources.sort_by(|a, b| {
605            resource_kind_order(a.kind)
606                .cmp(&resource_kind_order(b.kind))
607                .then_with(|| a.name.cmp(&b.name))
608        });
609        for resource in resources {
610            let layer = crate::resource::DiscoveryLayer {
611                root: resource.path,
612                subdirectory: None,
613                precedence: package.layer_precedence,
614            };
615            match resource.kind {
616                ResourceKind::Extension => result.extensions.push(layer),
617                ResourceKind::Skill => result.skills.push(layer),
618                ResourceKind::Fragment => result.fragments.push(layer),
619                ResourceKind::Theme => result.themes.push(layer),
620            }
621        }
622    }
623
624    result
625}
626
627fn resource_kind_order(kind: ResourceKind) -> u8 {
628    match kind {
629        ResourceKind::Extension => 0,
630        ResourceKind::Skill => 1,
631        ResourceKind::Fragment => 2,
632        ResourceKind::Theme => 3,
633    }
634}
635
636// ---------------------------------------------------------------------------
637// Registry
638// ---------------------------------------------------------------------------
639
640/// A registry of discovered packages supporting progressive disclosure and
641/// resource composition.
642pub struct PackageRegistry {
643    packages: Vec<PackageResource>,
644}
645
646impl PackageRegistry {
647    /// Build a registry from discovered package resources.
648    pub fn from_resources(packages: Vec<PackageResource>) -> Self {
649        Self { packages }
650    }
651
652    /// Return sorted list of all package names.
653    pub fn names(&self) -> Vec<&str> {
654        self.packages
655            .iter()
656            .map(|p| p.manifest.name.as_str())
657            .collect()
658    }
659
660    /// Look up a package by name, returning its resource (metadata only).
661    pub fn get(&self, name: &str) -> Option<&PackageResource> {
662        self.packages.iter().find(|p| p.manifest.name == name)
663    }
664
665    /// Format all package metadata as a string suitable for inclusion in a
666    /// system prompt or command listing.
667    pub fn format_for_prompt(&self) -> String {
668        if self.packages.is_empty() {
669            return String::new();
670        }
671
672        let parts: Vec<String> = self
673            .packages
674            .iter()
675            .map(|p| {
676                let version = p
677                    .manifest
678                    .version
679                    .as_deref()
680                    .map(|v| format!(" v{v}"))
681                    .unwrap_or_default();
682                format!(
683                    "- {}: {}{}",
684                    p.manifest.name, p.manifest.description, version
685                )
686            })
687            .collect();
688        parts.join("\n")
689    }
690}
691
692// ---------------------------------------------------------------------------
693// Validation helpers
694// ---------------------------------------------------------------------------
695
696/// Validate that a package name contains only allowed characters and is within
697/// length bounds.
698fn validate_package_name(name: &str, path: &Path) -> Result<(), PackageDiscoveryError> {
699    if name.len() > MAX_NAME_LEN {
700        return Err(PackageDiscoveryError::InvalidName {
701            path: path.to_path_buf(),
702            reason: format!(
703                "name exceeds maximum length of {MAX_NAME_LEN} characters ({} found)",
704                name.len()
705            ),
706        });
707    }
708
709    for ch in name.chars() {
710        let valid = ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-';
711        if !valid {
712            return Err(PackageDiscoveryError::InvalidName {
713                path: path.to_path_buf(),
714                reason: format!(
715                    "name contains invalid character '{ch}': \
716                     only lowercase a-z, 0-9, and hyphens are allowed"
717                ),
718            });
719        }
720    }
721
722    Ok(())
723}
724
725/// Validate that a description is within length bounds.
726fn validate_description(desc: &str, path: &Path) -> Result<(), PackageDiscoveryError> {
727    if desc.len() > MAX_DESCRIPTION_LEN {
728        return Err(PackageDiscoveryError::InvalidDescription {
729            path: path.to_path_buf(),
730            reason: format!(
731                "description exceeds maximum length of {MAX_DESCRIPTION_LEN} characters \
732                 ({} found)",
733                desc.len()
734            ),
735        });
736    }
737    Ok(())
738}