Skip to main content

roder_api/
packages.rs

1//! Canonical Roder package contracts (roadmap phase 97).
2//!
3//! A Roder package is an installable bundle of process extensions, skills,
4//! slash commands, and themes fetched from npm, git, or a local path with one
5//! command (`roder install npm:@foo/pkg`). These types are the canonical
6//! model shared by the fetch/store layer (`roder-config`), the activation
7//! layer (`roder-extension-host`), and the app-server protocol surface.
8//!
9//! Plugin authors publish a `roder.toml` manifest at the root of their
10//! repository (or a `roder` key in `package.json`) describing what the
11//! package provides and how to launch any process extensions.
12
13use std::collections::BTreeMap;
14use std::error::Error;
15use std::fmt;
16
17use serde::{Deserialize, Serialize};
18use time::OffsetDateTime;
19
20/// Settings file name inside a scope directory (`~/.roder/packages.json` or
21/// `<workspace>/.roder/packages.json`).
22pub const PACKAGES_SETTINGS_FILE: &str = "packages.json";
23
24/// Canonical package manifest file at the package (repository) root.
25pub const PACKAGE_MANIFEST_FILE: &str = "roder.toml";
26
27/// npm keyword recommended for discoverability of Roder packages.
28pub const PACKAGE_NPM_KEYWORD: &str = "roder-package";
29
30pub const EVENT_PACKAGE_INSTALLED: &str = "package.installed";
31pub const EVENT_PACKAGE_UPDATED: &str = "package.updated";
32pub const EVENT_PACKAGE_REMOVED: &str = "package.removed";
33pub const EVENT_PACKAGE_RESOURCE_TOGGLED: &str = "package.resource_toggled";
34pub const EVENT_PACKAGE_EXTENSIONS_APPROVED: &str = "package.extensions_approved";
35
36/// Where a package source was fetched from. The canonical spec string
37/// round-trips through [`parse_package_spec`] and [`PackageSource::spec`].
38#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
39#[serde(tag = "kind", rename_all = "camelCase")]
40pub enum PackageSource {
41    Npm {
42        name: String,
43        #[serde(default, skip_serializing_if = "Option::is_none")]
44        version: Option<String>,
45    },
46    Git {
47        url: String,
48        #[serde(rename = "refName")]
49        #[serde(default, skip_serializing_if = "Option::is_none")]
50        ref_name: Option<String>,
51    },
52    LocalPath {
53        path: String,
54    },
55}
56
57impl PackageSource {
58    /// Canonical spec string used in CLI output and settings files.
59    pub fn spec(&self) -> String {
60        match self {
61            PackageSource::Npm { name, version } => match version {
62                Some(version) => format!("npm:{name}@{version}"),
63                None => format!("npm:{name}"),
64            },
65            PackageSource::Git { url, ref_name } => match ref_name {
66                Some(ref_name) => format!("git:{url}@{ref_name}"),
67                None => format!("git:{url}"),
68            },
69            PackageSource::LocalPath { path } => path.clone(),
70        }
71    }
72
73    /// Stable identity: npm name, git URL without ref, or the path as given.
74    /// Local paths are resolved to absolute form by the settings layer, which
75    /// knows the resolution base.
76    pub fn identity(&self) -> PackageIdentity {
77        match self {
78            PackageSource::Npm { name, .. } => PackageIdentity(format!("npm:{name}")),
79            PackageSource::Git { url, .. } => {
80                PackageIdentity(format!("git:{}", normalize_git_identity(url)))
81            }
82            PackageSource::LocalPath { path } => PackageIdentity(format!("path:{path}")),
83        }
84    }
85
86    /// Pinned sources are skipped by bulk `roder update`.
87    pub fn pinned(&self) -> bool {
88        match self {
89            PackageSource::Npm { version, .. } => version.is_some(),
90            PackageSource::Git { ref_name, .. } => ref_name.is_some(),
91            PackageSource::LocalPath { .. } => false,
92        }
93    }
94}
95
96impl fmt::Display for PackageSource {
97    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
98        f.write_str(&self.spec())
99    }
100}
101
102fn normalize_git_identity(url: &str) -> String {
103    let trimmed = url.trim_end_matches('/');
104    trimmed
105        .strip_suffix(".git")
106        .unwrap_or(trimmed)
107        .to_ascii_lowercase()
108}
109
110/// Identity key for scope deduplication (project entry wins over user entry).
111#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, PartialOrd, Ord)]
112pub struct PackageIdentity(pub String);
113
114impl fmt::Display for PackageIdentity {
115    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
116        f.write_str(&self.0)
117    }
118}
119
120#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
121#[serde(rename_all = "camelCase")]
122pub enum PackageScope {
123    User,
124    Project,
125}
126
127impl fmt::Display for PackageScope {
128    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
129        match self {
130            PackageScope::User => f.write_str("user"),
131            PackageScope::Project => f.write_str("project"),
132        }
133    }
134}
135
136/// One installed (or configured) package in a scope's `packages.json`.
137#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
138#[serde(rename_all = "camelCase")]
139pub struct PackageRecord {
140    /// Short package id from the manifest (or derived from the source).
141    pub package_id: String,
142    pub identity: PackageIdentity,
143    pub source: PackageSource,
144    pub scope: PackageScope,
145    /// Materialized install root. `None` for local-path packages, which load
146    /// in place from `source`.
147    #[serde(default, skip_serializing_if = "Option::is_none")]
148    pub install_path: Option<String>,
149    /// Resolved npm version or git commit after install/update.
150    #[serde(default, skip_serializing_if = "Option::is_none")]
151    pub resolved: Option<String>,
152    #[serde(default = "default_true")]
153    pub enabled: bool,
154    /// Whether `--allow-scripts` was granted at install time.
155    #[serde(default)]
156    pub allow_scripts: bool,
157    /// Process extensions never launch until this is set by an explicit
158    /// approval step.
159    #[serde(default)]
160    pub extensions_approved: bool,
161    #[serde(with = "time::serde::rfc3339")]
162    pub installed_at: OffsetDateTime,
163    #[serde(default, skip_serializing_if = "Option::is_none")]
164    pub content_hash: Option<String>,
165    #[serde(default, skip_serializing_if = "PackageResourceFilters::is_empty")]
166    pub filters: PackageResourceFilters,
167    /// Resource ids (see [`PackageResource::id`]) disabled by the user.
168    #[serde(default, skip_serializing_if = "Vec::is_empty")]
169    pub disabled_resources: Vec<String>,
170}
171
172fn default_true() -> bool {
173    true
174}
175
176#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash, PartialOrd, Ord)]
177#[serde(rename_all = "camelCase")]
178pub enum PackageResourceKind {
179    Extension,
180    Skill,
181    Command,
182    Theme,
183}
184
185impl PackageResourceKind {
186    pub fn as_str(&self) -> &'static str {
187        match self {
188            PackageResourceKind::Extension => "extension",
189            PackageResourceKind::Skill => "skill",
190            PackageResourceKind::Command => "command",
191            PackageResourceKind::Theme => "theme",
192        }
193    }
194}
195
196impl fmt::Display for PackageResourceKind {
197    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
198        f.write_str(self.as_str())
199    }
200}
201
202impl std::str::FromStr for PackageResourceKind {
203    type Err = PackageError;
204
205    fn from_str(value: &str) -> Result<Self, Self::Err> {
206        match value.trim().to_ascii_lowercase().as_str() {
207            "extension" | "extensions" => Ok(Self::Extension),
208            "skill" | "skills" => Ok(Self::Skill),
209            "command" | "commands" => Ok(Self::Command),
210            "theme" | "themes" => Ok(Self::Theme),
211            other => Err(PackageError::InvalidResourceKind {
212                kind: other.to_string(),
213            }),
214        }
215    }
216}
217
218/// One enumerated resource inside an installed package.
219#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
220#[serde(rename_all = "camelCase")]
221pub struct PackageResource {
222    pub package_id: String,
223    pub kind: PackageResourceKind,
224    /// Path relative to the package root (slash-separated).
225    pub path: String,
226    /// Short resource name (skill name, command name, theme id, extension id).
227    pub name: String,
228    pub enabled: bool,
229    /// True for resources that execute code and therefore require approval.
230    pub requires_approval: bool,
231}
232
233impl PackageResource {
234    /// Registry-facing id: `<package-id>:<kind>/<name>`.
235    pub fn id(&self) -> String {
236        package_resource_id(&self.package_id, self.kind, &self.name)
237    }
238}
239
240pub fn package_resource_id(package_id: &str, kind: PackageResourceKind, name: &str) -> String {
241    format!("{package_id}:{kind}/{name}")
242}
243
244/// Parses `<package-id>:<kind>/<name>` back into its parts.
245pub fn parse_package_resource_id(
246    id: &str,
247) -> Result<(String, PackageResourceKind, String), PackageError> {
248    let (package_id, rest) = id.split_once(':').ok_or_else(|| invalid_resource_id(id))?;
249    let (kind, name) = rest
250        .split_once('/')
251        .ok_or_else(|| invalid_resource_id(id))?;
252    if package_id.is_empty() || name.is_empty() {
253        return Err(invalid_resource_id(id));
254    }
255    Ok((package_id.to_string(), kind.parse()?, name.to_string()))
256}
257
258fn invalid_resource_id(id: &str) -> PackageError {
259    PackageError::InvalidResourceId { id: id.to_string() }
260}
261
262/// Declared resources from `roder.toml` or the `package.json` `roder` key.
263/// Entries are package-root-relative paths or globs.
264#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
265#[serde(rename_all = "camelCase")]
266pub struct PackageManifestSpec {
267    pub id: String,
268    #[serde(default, skip_serializing_if = "Option::is_none")]
269    pub name: Option<String>,
270    #[serde(default, skip_serializing_if = "Option::is_none")]
271    pub version: Option<String>,
272    #[serde(default, skip_serializing_if = "Option::is_none")]
273    pub description: Option<String>,
274    /// Paths to process-extension manifests (`roder-extension.toml`).
275    #[serde(default, skip_serializing_if = "Vec::is_empty")]
276    pub extensions: Vec<String>,
277    #[serde(default, skip_serializing_if = "Vec::is_empty")]
278    pub skills: Vec<String>,
279    #[serde(default, skip_serializing_if = "Vec::is_empty")]
280    pub commands: Vec<String>,
281    #[serde(default, skip_serializing_if = "Vec::is_empty")]
282    pub themes: Vec<String>,
283}
284
285/// Per-type filter patterns layered on top of the manifest by settings.
286///
287/// Semantics (mirroring the documented contract):
288/// - `None` loads everything the manifest allows.
289/// - `Some([])` loads nothing of that type (except `+path` force-includes).
290/// - Plain patterns are include globs (`*` within a segment, `**` across).
291/// - `!pattern` excludes glob matches.
292/// - `+path` force-includes an exact path.
293/// - `-path` force-excludes an exact path.
294#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
295#[serde(rename_all = "camelCase")]
296pub struct PackageResourceFilters {
297    #[serde(default, skip_serializing_if = "Option::is_none")]
298    pub extensions: Option<Vec<String>>,
299    #[serde(default, skip_serializing_if = "Option::is_none")]
300    pub skills: Option<Vec<String>>,
301    #[serde(default, skip_serializing_if = "Option::is_none")]
302    pub commands: Option<Vec<String>>,
303    #[serde(default, skip_serializing_if = "Option::is_none")]
304    pub themes: Option<Vec<String>>,
305}
306
307impl PackageResourceFilters {
308    pub fn is_empty(&self) -> bool {
309        self.extensions.is_none()
310            && self.skills.is_none()
311            && self.commands.is_none()
312            && self.themes.is_none()
313    }
314
315    pub fn for_kind(&self, kind: PackageResourceKind) -> Option<&[String]> {
316        match kind {
317            PackageResourceKind::Extension => self.extensions.as_deref(),
318            PackageResourceKind::Skill => self.skills.as_deref(),
319            PackageResourceKind::Command => self.commands.as_deref(),
320            PackageResourceKind::Theme => self.themes.as_deref(),
321        }
322    }
323
324    pub fn set_for_kind(&mut self, kind: PackageResourceKind, patterns: Option<Vec<String>>) {
325        match kind {
326            PackageResourceKind::Extension => self.extensions = patterns,
327            PackageResourceKind::Skill => self.skills = patterns,
328            PackageResourceKind::Command => self.commands = patterns,
329            PackageResourceKind::Theme => self.themes = patterns,
330        }
331    }
332
333    /// Whether `path` (package-root-relative, slash-separated) passes the
334    /// filter configured for `kind`.
335    pub fn allows(&self, kind: PackageResourceKind, path: &str) -> bool {
336        filter_allows(self.for_kind(kind), path)
337    }
338}
339
340fn filter_allows(patterns: Option<&[String]>, path: &str) -> bool {
341    let Some(patterns) = patterns else {
342        return true;
343    };
344    let mut includes = Vec::new();
345    let mut excludes = Vec::new();
346    for pattern in patterns {
347        if let Some(exact) = pattern.strip_prefix('-') {
348            if exact == path {
349                return false;
350            }
351        } else if let Some(exact) = pattern.strip_prefix('+') {
352            if exact == path {
353                return true;
354            }
355        } else if let Some(glob) = pattern.strip_prefix('!') {
356            excludes.push(glob);
357        } else {
358            includes.push(pattern.as_str());
359        }
360    }
361    if excludes.iter().any(|glob| glob_match(glob, path)) {
362        return false;
363    }
364    if includes.is_empty() {
365        // `Some([])` (or only force/exclude entries) loads nothing by default.
366        return false;
367    }
368    includes.iter().any(|glob| glob_match(glob, path))
369}
370
371/// Segment-wise glob match. `*` matches within one path segment, `**` matches
372/// any number of segments. A pattern that matches a leading directory prefix
373/// of `path` also counts (so `skills` matches `skills/foo/SKILL.md`).
374pub fn glob_match(pattern: &str, path: &str) -> bool {
375    let pattern_segments: Vec<&str> = pattern.split('/').filter(|s| !s.is_empty()).collect();
376    let path_segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
377    segments_match(&pattern_segments, &path_segments)
378}
379
380fn segments_match(pattern: &[&str], path: &[&str]) -> bool {
381    match pattern.first() {
382        None => true, // pattern exhausted: exact match or directory prefix
383        Some(&"**") => {
384            if segments_match(&pattern[1..], path) {
385                return true;
386            }
387            if path.is_empty() {
388                return false;
389            }
390            segments_match(pattern, &path[1..])
391        }
392        Some(first) => {
393            let Some(segment) = path.first() else {
394                return false;
395            };
396            segment_match(first, segment) && segments_match(&pattern[1..], &path[1..])
397        }
398    }
399}
400
401fn segment_match(pattern: &str, segment: &str) -> bool {
402    let parts: Vec<&str> = pattern.split('*').collect();
403    if parts.len() == 1 {
404        return pattern == segment;
405    }
406    let mut rest = segment;
407    for (index, part) in parts.iter().enumerate() {
408        if index == 0 {
409            let Some(after) = rest.strip_prefix(part) else {
410                return false;
411            };
412            rest = after;
413        } else if index == parts.len() - 1 {
414            return rest.ends_with(part);
415        } else if part.is_empty() {
416            continue;
417        } else {
418            let Some(found) = rest.find(part) else {
419                return false;
420            };
421            rest = &rest[found + part.len()..];
422        }
423    }
424    true
425}
426
427/// Parses one package spec string into a [`PackageSource`].
428///
429/// Accepted forms:
430/// - `npm:@scope/pkg`, `npm:@scope/pkg@1.2.3`, `npm:pkg`
431/// - `git:github.com/user/repo[@ref]`, `git:git@host:user/repo[@ref]`,
432///   `git:<protocol-url>[@ref]`
433/// - protocol URLs without prefix: `https://`, `http://`, `ssh://`, `git://`
434/// - local paths: absolute, `./relative`, `../relative`, `~/path`
435pub fn parse_package_spec(input: &str) -> Result<PackageSource, PackageError> {
436    let input = input.trim();
437    if input.is_empty() {
438        return Err(PackageError::InvalidSpec {
439            spec: input.to_string(),
440            reason: "spec is empty".to_string(),
441        });
442    }
443    if let Some(rest) = input.strip_prefix("npm:") {
444        return parse_npm_spec(input, rest);
445    }
446    if input.starts_with("git://") {
447        return parse_git_spec(input, input);
448    }
449    if let Some(rest) = input.strip_prefix("git:") {
450        return parse_git_spec(input, rest);
451    }
452    if ["https://", "http://", "ssh://", "file://"]
453        .iter()
454        .any(|scheme| input.starts_with(scheme))
455    {
456        return parse_git_spec(input, input);
457    }
458    if input.starts_with('/')
459        || input.starts_with("./")
460        || input.starts_with("../")
461        || input.starts_with("~/")
462        || input == "."
463        || input == ".."
464    {
465        return Ok(PackageSource::LocalPath {
466            path: input.to_string(),
467        });
468    }
469    Err(PackageError::InvalidSpec {
470        spec: input.to_string(),
471        reason: "expected npm:<name>[@version], git:<url>[@ref], a protocol URL, or a local path"
472            .to_string(),
473    })
474}
475
476fn parse_npm_spec(original: &str, rest: &str) -> Result<PackageSource, PackageError> {
477    let invalid = |reason: &str| PackageError::InvalidSpec {
478        spec: original.to_string(),
479        reason: reason.to_string(),
480    };
481    if rest.is_empty() {
482        return Err(invalid("npm spec is missing a package name"));
483    }
484    let (name, version) = if let Some(scoped) = rest.strip_prefix('@') {
485        match scoped.split_once('@') {
486            Some((name, version)) => (format!("@{name}"), Some(version.to_string())),
487            None => (format!("@{scoped}"), None),
488        }
489    } else {
490        match rest.split_once('@') {
491            Some((name, version)) => (name.to_string(), Some(version.to_string())),
492            None => (rest.to_string(), None),
493        }
494    };
495    if name == "@" || name.is_empty() {
496        return Err(invalid("npm spec is missing a package name"));
497    }
498    if name.starts_with('@') && !name[1..].contains('/') {
499        return Err(invalid("scoped npm names must look like @scope/name"));
500    }
501    if let Some(version) = &version
502        && version.is_empty()
503    {
504        return Err(invalid("npm version after @ is empty"));
505    }
506    if name.contains("..") || name.contains(' ') {
507        return Err(invalid("npm package name contains invalid characters"));
508    }
509    Ok(PackageSource::Npm { name, version })
510}
511
512fn parse_git_spec(original: &str, rest: &str) -> Result<PackageSource, PackageError> {
513    let invalid = |reason: &str| PackageError::InvalidSpec {
514        spec: original.to_string(),
515        reason: reason.to_string(),
516    };
517    if rest.is_empty() {
518        return Err(invalid("git spec is missing a repository"));
519    }
520    let (location, ref_name) = split_git_ref(rest);
521    if location.is_empty() {
522        return Err(invalid("git spec is missing a repository"));
523    }
524    if let Some(ref_name) = &ref_name
525        && ref_name.is_empty()
526    {
527        return Err(invalid("git ref after @ is empty"));
528    }
529    let has_protocol = location.contains("://");
530    let is_scp_form = !has_protocol && location.contains('@') && location.contains(':');
531    let is_shorthand = !has_protocol && !is_scp_form && location.contains('/');
532    if !has_protocol && !is_scp_form && !is_shorthand {
533        return Err(invalid(
534            "git spec must be host/user/repo shorthand, user@host:path, or a protocol URL",
535        ));
536    }
537    let url = if is_shorthand {
538        format!("https://{location}")
539    } else {
540        location.to_string()
541    };
542    Ok(PackageSource::Git {
543        url,
544        ref_name: ref_name.map(str::to_string),
545    })
546}
547
548/// Splits a trailing `@ref` when the `@` appears after the last `/` and the
549/// last `:`, so user-info `@` in ssh and scp forms is never mistaken for a
550/// ref separator.
551fn split_git_ref(input: &str) -> (&str, Option<&str>) {
552    let Some(at) = input.rfind('@') else {
553        return (input, None);
554    };
555    let last_slash = input.rfind('/');
556    let last_colon = input.rfind(':');
557    let boundary = last_slash.max(last_colon);
558    match boundary {
559        Some(boundary) if at > boundary => (&input[..at], Some(&input[at + 1..])),
560        _ => (input, None),
561    }
562}
563
564/// Package ids are lowercase alphanumeric with `-`, `_`, and `.` separators.
565pub fn validate_package_id(id: &str) -> Result<(), PackageError> {
566    let valid = !id.is_empty()
567        && id.len() <= 100
568        && id
569            .chars()
570            .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '-' | '_' | '.'))
571        && id.chars().next().is_some_and(|c| c.is_ascii_alphanumeric());
572    if valid {
573        Ok(())
574    } else {
575        Err(PackageError::InvalidPackageId { id: id.to_string() })
576    }
577}
578
579/// Derives a fallback package id from the source when the manifest does not
580/// declare one (npm name tail, git repo name, or directory name).
581pub fn derive_package_id(source: &PackageSource) -> String {
582    let raw = match source {
583        PackageSource::Npm { name, .. } => name.rsplit('/').next().unwrap_or(name).to_string(),
584        PackageSource::Git { url, .. } => {
585            let trimmed = url.trim_end_matches('/');
586            let trimmed = trimmed.strip_suffix(".git").unwrap_or(trimmed);
587            trimmed
588                .rsplit(['/', ':'])
589                .next()
590                .unwrap_or(trimmed)
591                .to_string()
592        }
593        PackageSource::LocalPath { path } => {
594            let trimmed = path.trim_end_matches('/');
595            trimmed
596                .rsplit(['/', '\\'])
597                .next()
598                .filter(|name| !name.is_empty())
599                .unwrap_or("package")
600                .to_string()
601        }
602    };
603    let mut id: String = raw
604        .to_ascii_lowercase()
605        .chars()
606        .map(|c| {
607            if c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '-' | '_' | '.') {
608                c
609            } else {
610                '-'
611            }
612        })
613        .collect();
614    while id.starts_with(['-', '_', '.']) {
615        id.remove(0);
616    }
617    if id.is_empty() {
618        id = "package".to_string();
619    }
620    id
621}
622
623/// Launch description for a process extension shipped inside a package; also
624/// usable by hand-written `roder-extension.toml` manifests. Relative paths in
625/// `args` and `cwd` resolve against the manifest's directory.
626#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
627#[serde(rename_all = "snake_case")]
628pub struct PackageExtensionLaunch {
629    pub command: String,
630    #[serde(default)]
631    pub args: Vec<String>,
632    #[serde(default)]
633    pub cwd: Option<String>,
634    #[serde(default)]
635    pub env: BTreeMap<String, String>,
636    #[serde(default)]
637    pub startup_timeout_ms: Option<u64>,
638    #[serde(default)]
639    pub event_filter_kinds: Vec<String>,
640}
641
642#[derive(Debug, Clone, PartialEq, Eq)]
643pub enum PackageError {
644    InvalidSpec { spec: String, reason: String },
645    InvalidPackageId { id: String },
646    InvalidResourceKind { kind: String },
647    InvalidResourceId { id: String },
648    DuplicatePackage { identity: String, scope: String },
649    PackageNotFound { spec: String },
650}
651
652impl fmt::Display for PackageError {
653    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
654        match self {
655            PackageError::InvalidSpec { spec, reason } => {
656                write!(f, "invalid package spec {spec:?}: {reason}")
657            }
658            PackageError::InvalidPackageId { id } => write!(
659                f,
660                "invalid package id {id:?}: ids are lowercase alphanumeric plus '-', '_', '.'"
661            ),
662            PackageError::InvalidResourceKind { kind } => write!(
663                f,
664                "invalid resource kind {kind:?}: expected extension, skill, command, or theme"
665            ),
666            PackageError::InvalidResourceId { id } => write!(
667                f,
668                "invalid resource id {id:?}: expected <package-id>:<kind>/<name>"
669            ),
670            PackageError::DuplicatePackage { identity, scope } => {
671                write!(
672                    f,
673                    "package {identity} is already installed in {scope} scope"
674                )
675            }
676            PackageError::PackageNotFound { spec } => {
677                write!(f, "package {spec} is not installed")
678            }
679        }
680    }
681}
682
683impl Error for PackageError {}