Skip to main content

yapi_core/chest/
manifest.rs

1//! `chest.toml`: what a project installs, in `[skills]`, `[agents]`,
2//! `[extensions]` and `[patch]`, and what a package publishes, in
3//! `[package]`.
4
5use std::collections::HashMap;
6use std::fmt;
7use std::path::{Component, Path, PathBuf};
8
9use indexmap::IndexMap;
10use serde::de::{DeserializeOwned, IntoDeserializer as _};
11use serde::{Deserialize, Serialize};
12use serde_json::Value;
13use toml_edit::{DocumentMut, Item};
14
15use super::{ChestError, invalid};
16use crate::extensions::{EventPolicy, Grants};
17use crate::packages::source::{self, Source};
18use crate::settings::Scope;
19use crate::tools::path::{expand_home, home_dir, resolve_lexically};
20
21/// A manifest section of named entries.
22#[derive(Clone, Copy, Debug, PartialEq, Eq)]
23pub enum Section {
24    /// `[skills]`: folders holding a `SKILL.md`.
25    Skills,
26    /// `[agents]`: subagent `.md` files.
27    Agents,
28    /// `[extensions]`: pi packages and native extensions.
29    Extensions,
30    /// `[patch]`: local folders that stand in for entries of the others.
31    Patch,
32}
33
34impl Section {
35    /// Every section, in manifest order.
36    pub const ALL: [Section; 4] = [
37        Section::Skills,
38        Section::Agents,
39        Section::Extensions,
40        Section::Patch,
41    ];
42
43    /// Its key in the manifest.
44    pub fn key(self) -> &'static str {
45        match self {
46            Section::Skills => "skills",
47            Section::Agents => "agents",
48            Section::Extensions => "extensions",
49            Section::Patch => "patch",
50        }
51    }
52}
53
54/// `[folders]`: where installed items go, relative to the manifest.
55#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
56#[serde(deny_unknown_fields)]
57pub struct Folders {
58    /// Skill folders. The first holds the canonical copies, the others link
59    /// to them.
60    #[serde(default, skip_serializing_if = "Vec::is_empty")]
61    pub skills: Vec<String>,
62    /// Subagent folders, placed as skills are.
63    #[serde(default, skip_serializing_if = "Vec::is_empty")]
64    pub agents: Vec<String>,
65    /// The extension store, with one folder per entry.
66    #[serde(default, skip_serializing_if = "Option::is_none")]
67    pub extensions: Option<String>,
68}
69
70/// An entry of a section. Its source is `git` or `npm`, each with an
71/// optional subfolder `path`, or `path` alone, a path relative to the
72/// manifest. The keys after `path` belong to `[extensions]` entries only.
73#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
74#[serde(deny_unknown_fields)]
75pub struct Entry {
76    /// A git repository.
77    #[serde(default, skip_serializing_if = "Option::is_none")]
78    pub git: Option<String>,
79    /// The branch, tag or commit of `git`.
80    #[serde(default, skip_serializing_if = "Option::is_none")]
81    pub rev: Option<String>,
82    /// An npm package name.
83    #[serde(default, skip_serializing_if = "Option::is_none")]
84    pub npm: Option<String>,
85    /// The version range or dist-tag of `npm`.
86    #[serde(default, skip_serializing_if = "Option::is_none")]
87    pub version: Option<String>,
88    /// The subfolder of the git or npm package, or the source itself.
89    #[serde(default, skip_serializing_if = "Option::is_none")]
90    pub path: Option<String>,
91    /// The capabilities granted, with the manifest's defaults. Absent grants
92    /// none of its own.
93    #[serde(default, skip_serializing_if = "Option::is_none")]
94    pub capabilities: Option<Vec<String>>,
95    /// Which `pi.events` channels cross to and from other grants. Absent
96    /// is shared.
97    #[serde(default, skip_serializing_if = "Option::is_none")]
98    pub events: Option<EventPolicy>,
99    /// The package's skills to load, as a settings package filter.
100    #[serde(default, skip_serializing_if = "Option::is_none")]
101    pub skills: Option<Vec<String>>,
102    /// The package's prompt templates to load, as a settings package filter.
103    #[serde(default, skip_serializing_if = "Option::is_none")]
104    pub prompts: Option<Vec<String>>,
105    /// The package's themes to load, as a settings package filter.
106    #[serde(default, skip_serializing_if = "Option::is_none")]
107    pub themes: Option<Vec<String>>,
108}
109
110/// `[package]`: what a package says about itself. Its `capabilities` and
111/// `events` are requests, which never grant anything by themselves. A
112/// resource list holds paths and globs, with `!` excludes, relative to the
113/// package.
114#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
115#[serde(deny_unknown_fields)]
116pub struct Package {
117    /// The package name.
118    #[serde(default, skip_serializing_if = "Option::is_none")]
119    pub name: Option<String>,
120    /// Its version.
121    #[serde(default, skip_serializing_if = "Option::is_none")]
122    pub version: Option<String>,
123    /// A one-line description.
124    #[serde(default, skip_serializing_if = "Option::is_none")]
125    pub description: Option<String>,
126    /// Its authors, as `Name <email>`.
127    #[serde(default, skip_serializing_if = "Vec::is_empty")]
128    pub authors: Vec<String>,
129    /// An SPDX license expression.
130    #[serde(default, skip_serializing_if = "Option::is_none")]
131    pub license: Option<String>,
132    /// Its source repository URL.
133    #[serde(default, skip_serializing_if = "Option::is_none")]
134    pub repository: Option<String>,
135    /// Its home page URL.
136    #[serde(default, skip_serializing_if = "Option::is_none")]
137    pub homepage: Option<String>,
138    /// Search keywords.
139    #[serde(default, skip_serializing_if = "Vec::is_empty")]
140    pub keywords: Vec<String>,
141    /// The capabilities its extensions need.
142    #[serde(default, skip_serializing_if = "Option::is_none")]
143    pub capabilities: Option<Vec<String>>,
144    /// The `pi.events` channels its extensions use.
145    #[serde(default, skip_serializing_if = "Option::is_none")]
146    pub events: Option<EventPolicy>,
147    /// Its skills.
148    #[serde(default, skip_serializing_if = "Option::is_none")]
149    pub skills: Option<Vec<String>>,
150    /// Its subagents.
151    #[serde(default, skip_serializing_if = "Option::is_none")]
152    pub agents: Option<Vec<String>>,
153    /// Its extension entry files.
154    #[serde(default, skip_serializing_if = "Option::is_none")]
155    pub extensions: Option<Vec<String>>,
156    /// Its prompt templates.
157    #[serde(default, skip_serializing_if = "Option::is_none")]
158    pub prompts: Option<Vec<String>>,
159    /// Its themes.
160    #[serde(default, skip_serializing_if = "Option::is_none")]
161    pub themes: Option<Vec<String>>,
162}
163
164impl Package {
165    /// Its resource list of `kind`, one of [`RESOURCE_KINDS`].
166    fn list(&self, kind: &str) -> Option<&Vec<String>> {
167        match kind {
168            "extensions" => self.extensions.as_ref(),
169            "skills" => self.skills.as_ref(),
170            "prompts" => self.prompts.as_ref(),
171            "themes" => self.themes.as_ref(),
172            "agents" => self.agents.as_ref(),
173            _ => None,
174        }
175    }
176}
177
178/// A valid `chest.toml`. Section maps keep manifest order, which is load
179/// order.
180#[derive(Clone, Debug, Default, PartialEq, Eq)]
181pub struct Manifest {
182    /// Capabilities every extension entry of this manifest gets.
183    pub capabilities_defaults: Vec<String>,
184    /// `[folders]`.
185    pub folders: Folders,
186    /// `[skills]`.
187    pub skills: IndexMap<String, Entry>,
188    /// `[agents]`.
189    pub agents: IndexMap<String, Entry>,
190    /// `[extensions]`.
191    pub extensions: IndexMap<String, Entry>,
192    /// `[patch]`, by the name of the entry each replaces.
193    pub patch: IndexMap<String, Entry>,
194    /// `[package]`.
195    pub package: Option<Package>,
196}
197
198impl Manifest {
199    /// The manifest in `text`. Errors name the entry and key at fault, such
200    /// as `skills.pdf`.
201    ///
202    /// Unknown keys are errors. Entry names match `[a-z0-9][a-z0-9._-]*` and
203    /// are unique across `[skills]`, `[agents]` and `[extensions]`. An entry
204    /// takes `git` or `npm`, each with an optional `path`, or `path` alone.
205    /// It takes `rev` only with `git` and `version` only with `npm`. A
206    /// `[patch]` entry takes `path` alone. Capability lists must parse as
207    /// [`Grants`]. In a project manifest, `[folders]` paths are relative,
208    /// without `..` or a leading `~`, so they stay inside the project. The
209    /// user's global manifest may name any folder here, but installs only
210    /// under the home folder.
211    pub fn parse(text: &str, scope: Scope) -> Result<Manifest, ChestError> {
212        let document: DocumentMut = text.parse().map_err(|err| invalid(format!("{err}")))?;
213        Manifest::from_document(&document, scope)
214    }
215
216    fn from_document(document: &DocumentMut, scope: Scope) -> Result<Manifest, ChestError> {
217        let mut manifest = Manifest::default();
218        for (key, item) in document.iter() {
219            match key {
220                "capabilities-defaults" => manifest.capabilities_defaults = value(key, item)?,
221                "folders" => manifest.folders = value(key, item)?,
222                "package" => manifest.package = Some(value(key, item)?),
223                _ => {
224                    let section = Section::ALL
225                        .into_iter()
226                        .find(|section| section.key() == key)
227                        .ok_or_else(|| invalid(format!("unknown key `{key}`")))?;
228                    let table = item
229                        .as_table_like()
230                        .ok_or_else(|| invalid(format!("{key}: expected a table")))?;
231                    for (name, item) in table.iter() {
232                        let entry = value(&format!("{key}.{name}"), item)?;
233                        manifest.section_mut(section).insert(name.to_owned(), entry);
234                    }
235                }
236            }
237        }
238        manifest.validate(scope)?;
239        Ok(manifest)
240    }
241
242    /// The entries of `section`.
243    pub fn section(&self, section: Section) -> &IndexMap<String, Entry> {
244        match section {
245            Section::Skills => &self.skills,
246            Section::Agents => &self.agents,
247            Section::Extensions => &self.extensions,
248            Section::Patch => &self.patch,
249        }
250    }
251
252    /// The skill, agent and extension entries, in manifest order.
253    pub fn entries(&self) -> impl Iterator<Item = (Section, &String, &Entry)> {
254        let sections = [Section::Skills, Section::Agents, Section::Extensions];
255        sections.into_iter().flat_map(move |section| {
256            let entries = self.section(section).iter();
257            entries.map(move |(name, entry)| (section, name, entry))
258        })
259    }
260
261    fn section_mut(&mut self, section: Section) -> &mut IndexMap<String, Entry> {
262        match section {
263            Section::Skills => &mut self.skills,
264            Section::Agents => &mut self.agents,
265            Section::Extensions => &mut self.extensions,
266            Section::Patch => &mut self.patch,
267        }
268    }
269
270    /// The grants of extension `entry`: this manifest's
271    /// `capabilities-defaults` with the entry's own `capabilities`. A list
272    /// that does not parse, which [`Manifest::parse`] refuses, grants
273    /// nothing.
274    pub fn grants(&self, entry: &Entry) -> Grants {
275        let grants = |values: &[String]| Grants::parse(values).unwrap_or_else(|_| Grants::none());
276        let own = entry.capabilities.as_deref().unwrap_or_default();
277        grants(&self.capabilities_defaults).union(grants(own))
278    }
279
280    fn validate(&self, scope: Scope) -> Result<(), ChestError> {
281        Grants::parse(&self.capabilities_defaults)
282            .map_err(|err| invalid(format!("capabilities-defaults: {err}")))?;
283        let folders = &self.folders;
284        let skills = folders.skills.iter().map(|path| ("skills", path));
285        let agents = folders.agents.iter().map(|path| ("agents", path));
286        let store = folders.extensions.iter().map(|path| ("extensions", path));
287        let named: Vec<(&str, &String)> = skills.chain(agents).chain(store).collect();
288        for (key, path) in &named {
289            let expanded = expand_home(path);
290            if !Path::new(&expanded)
291                .components()
292                .any(|part| matches!(part, Component::Normal(_)))
293            {
294                return Err(invalid(format!(
295                    "folders.{key}: \"{path}\" must name a folder"
296                )));
297            }
298            let parts = Path::new(path).components();
299            let inside = !path.is_empty()
300                && !path.starts_with('~')
301                && parts
302                    .into_iter()
303                    .all(|part| matches!(part, Component::Normal(_) | Component::CurDir));
304            if scope == Scope::Project && !inside {
305                return Err(invalid(format!(
306                    "folders.{key}: \"{path}\" must be a relative path inside the project, without `..` or `~`"
307                )));
308            }
309        }
310        // No folder may hold another, or the store, or the store them.
311        let default_store = match scope {
312            Scope::Project => ".yapi/chest",
313            Scope::Global => "~/.chest/store",
314        };
315        let mut paths: Vec<&str> = named.iter().map(|(_, path)| path.as_str()).collect();
316        if folders.extensions.is_none() {
317            paths.push(default_store);
318        }
319        let base = match scope {
320            Scope::Project => PathBuf::from("/"),
321            Scope::Global => home_dir().join(".chest"),
322        };
323        let normal = |path: &str| resolve_lexically(&base, Path::new(&expand_home(path)));
324        for (index, first) in paths.iter().enumerate() {
325            for second in &paths[index + 1..] {
326                let (a, b) = (normal(first), normal(second));
327                if a.starts_with(&b) || b.starts_with(&a) {
328                    return Err(invalid(format!(
329                        "folders: \"{first}\" and \"{second}\" overlap"
330                    )));
331                }
332            }
333        }
334        let mut seen: HashMap<&str, Section> = HashMap::new();
335        for section in [Section::Skills, Section::Agents, Section::Extensions] {
336            for (name, entry) in self.section(section) {
337                let context = format!("{}.{name}", section.key());
338                if !valid_name(name) {
339                    return Err(invalid(format!(
340                        "{context}: names use lowercase letters, digits, `.`, `_` and `-`, and start with a letter or digit"
341                    )));
342                }
343                if let Some(other) = seen.insert(name, section) {
344                    return Err(invalid(format!(
345                        "{context}: the name is already used in [{}]",
346                        other.key()
347                    )));
348                }
349                entry.validate(section, &context, scope)?;
350            }
351        }
352        for (name, entry) in &self.patch {
353            let path_only = Entry {
354                path: entry.path.clone(),
355                ..Entry::default()
356            };
357            let Some(path) = entry.path.as_deref().filter(|_| *entry == path_only) else {
358                return Err(invalid(format!("patch.{name}: a patch takes `path` only")));
359            };
360            check_source(path, scope).map_err(|err| invalid(format!("patch.{name}: {err}")))?;
361        }
362        if let Some(capabilities) = self.package.as_ref().and_then(|p| p.capabilities.as_ref()) {
363            Grants::parse(capabilities)
364                .map_err(|err| invalid(format!("package: `capabilities`: {err}")))?;
365        }
366        Ok(())
367    }
368}
369
370impl Entry {
371    fn validate(&self, section: Section, context: &str, scope: Scope) -> Result<(), ChestError> {
372        let fail = |message: String| Err(invalid(format!("{context}: {message}")));
373        match (&self.git, &self.npm, &self.path) {
374            (Some(_), Some(_), _) => return fail("`git` and `npm` cannot be combined".into()),
375            (None, None, None) => return fail("needs `git`, `npm` or `path`".into()),
376            _ => {}
377        }
378        if self.rev.is_some() && self.git.is_none() {
379            return fail("`rev` needs `git`".into());
380        }
381        if self.version.is_some() && self.npm.is_none() {
382            return fail("`version` needs `npm`".into());
383        }
384        if self.rev.as_deref().is_some_and(|rev| rev.starts_with('-')) {
385            return fail("`rev` must not start with `-`".into());
386        }
387        match (&self.path, self.git.is_some() || self.npm.is_some()) {
388            // An extension's folder is its module root, so it must be the package.
389            (Some(path), false)
390                if section == Section::Extensions
391                    && !Path::new(path)
392                        .components()
393                        .any(|part| matches!(part, Component::Normal(_))) =>
394            {
395                return fail(format!(
396                    "`path`: \"{path}\" must name the extension package, not a folder that holds the manifest"
397                ));
398            }
399            (Some(path), false) => check_source(path, scope).or_else(fail)?,
400            (Some(path), true) => {
401                let parts = Path::new(path).components();
402                let inside = parts
403                    .into_iter()
404                    .all(|part| matches!(part, Component::Normal(_) | Component::CurDir));
405                if path.is_empty() || !inside {
406                    return fail(format!(
407                        "`path`: \"{path}\" must be a folder inside the package"
408                    ));
409                }
410            }
411            (None, _) => {}
412        }
413        if let Some(git) = &self.git {
414            match source::parse(&format!("git:{git}")) {
415                Source::Git {
416                    reference: Some(reference),
417                    ..
418                } => {
419                    return fail(format!(
420                        "`git`: \"{git}\" names the revision `{reference}`: put it in `rev`"
421                    ));
422                }
423                Source::Git { .. } => {}
424                _ => return fail(format!("`git`: not a git repository: {git}")),
425            }
426        }
427        if section == Section::Extensions {
428            if self.npm.is_some() && self.path.is_some() {
429                return fail(
430                    "`path` cannot be combined with `npm` for an extension, which loads its whole npm package"
431                        .into(),
432                );
433            }
434            if let Some(Err(err)) = self.capabilities.as_deref().map(Grants::parse) {
435                return fail(format!("`capabilities`: {err}"));
436            }
437            return Ok(());
438        }
439        let extension_only = [
440            ("capabilities", self.capabilities.is_some()),
441            ("events", self.events.is_some()),
442            ("skills", self.skills.is_some()),
443            ("prompts", self.prompts.is_some()),
444            ("themes", self.themes.is_some()),
445        ];
446        match extension_only.iter().find(|(_, set)| *set) {
447            Some((key, _)) => fail(format!("`{key}` belongs to [extensions] entries")),
448            None => Ok(()),
449        }
450    }
451}
452
453/// Fails unless a path source `path` is allowed in a manifest of `scope`:
454/// in a project, relative to the manifest. It may climb out with `..`,
455/// which the install notes.
456fn check_source(path: &str, scope: Scope) -> Result<(), String> {
457    let anchored = path.starts_with('~') || Path::new(path).has_root();
458    if scope == Scope::Project && anchored {
459        return Err(format!(
460            "`path`: \"{path}\" must be relative to the manifest, not absolute or under `~`"
461        ));
462    }
463    Ok(())
464}
465
466/// The environment variable that names a project's manifest, relative to
467/// the project's folder, for chest and yapi alike.
468pub const MANIFEST_ENV: &str = "CHEST_MANIFEST";
469
470/// The manifest file [`MANIFEST_ENV`] names, if any. `Err` when it names a
471/// file outside the project: an absolute path, one under `~`, or one with
472/// `..`.
473pub fn from_env() -> Result<Option<String>, String> {
474    let Some(name) = std::env::var(MANIFEST_ENV)
475        .ok()
476        .filter(|name| !name.is_empty())
477    else {
478        return Ok(None);
479    };
480    let inside = !name.starts_with('~')
481        && Path::new(&name)
482            .components()
483            .all(|part| matches!(part, Component::Normal(_) | Component::CurDir));
484    if inside {
485        Ok(Some(name))
486    } else {
487        Err(format!(
488            "{MANIFEST_ENV} must name a file inside the project, not \"{name}\""
489        ))
490    }
491}
492
493/// Whether `name` matches `[a-z0-9][a-z0-9._-]*`, the rule for entry and
494/// item names, which keeps each a single path segment.
495pub fn valid_name(name: &str) -> bool {
496    let lower_or_digit = |c: char| c.is_ascii_lowercase() || c.is_ascii_digit();
497    let mut chars = name.chars();
498    chars.next().is_some_and(lower_or_digit)
499        && chars.all(|c| lower_or_digit(c) || matches!(c, '.' | '_' | '-'))
500}
501
502/// `item` read as `T`, every field of which is optional. Errors start with
503/// `context` and, unless the message names it, the key at fault.
504fn value<T: DeserializeOwned>(context: &str, item: &Item) -> Result<T, ChestError> {
505    let read = |value: toml_edit::Value| T::deserialize(value.into_deserializer());
506    let value = item
507        .clone()
508        .into_value()
509        .map_err(|_| invalid(format!("{context}: expected a value")))?;
510    read(value.clone()).map_err(|err| {
511        let message = err.message();
512        // With every field optional, the key at fault fails on its own too.
513        let key = value.as_inline_table().and_then(|table| {
514            let fails = |(key, value): &(&str, &toml_edit::Value)| {
515                let mut alone = toml_edit::InlineTable::new();
516                alone.insert(*key, (*value).clone());
517                read(toml_edit::Value::InlineTable(alone)).is_err()
518            };
519            table.iter().find(fails).map(|(key, _)| key)
520        });
521        match key {
522            Some(key) if !message.contains(&format!("`{key}`")) => {
523                invalid(format!("{context}: `{key}`: {message}"))
524            }
525            _ => invalid(format!("{context}: {message}")),
526        }
527    })
528}
529
530/// A `chest.toml` being edited. Edits keep its comments and formatting, and
531/// apply only when the result is still a valid manifest.
532#[derive(Clone, Debug)]
533pub struct ManifestDocument {
534    document: DocumentMut,
535    manifest: Manifest,
536    scope: Scope,
537}
538
539impl ManifestDocument {
540    /// The manifest in `text`, ready to edit, refused as [`Manifest::parse`]
541    /// refuses it for `scope`.
542    pub fn parse(text: &str, scope: Scope) -> Result<ManifestDocument, ChestError> {
543        let document: DocumentMut = text.parse().map_err(|err| invalid(format!("{err}")))?;
544        let manifest = Manifest::from_document(&document, scope)?;
545        Ok(ManifestDocument {
546            document,
547            manifest,
548            scope,
549        })
550    }
551
552    /// The manifest as edited so far.
553    pub fn manifest(&self) -> &Manifest {
554        &self.manifest
555    }
556
557    /// Adds entry `name` to `section`, or replaces it in its place and with
558    /// its comments.
559    pub fn set_entry(
560        &mut self,
561        section: Section,
562        name: &str,
563        entry: &Entry,
564    ) -> Result<(), ChestError> {
565        let context = format!("{}.{name}", section.key());
566        let value = entry
567            .serialize(toml_edit::ser::ValueSerializer::new())
568            .map_err(|err| invalid(format!("{context}: {err}")))?;
569        self.edit(|document| {
570            if !document.contains_key(section.key()) {
571                let mut table = toml_edit::Table::new();
572                table.set_position(Some(next_position(document)));
573                document.insert(section.key(), Item::Table(table));
574            }
575            let table = document[section.key()]
576                .as_table_like_mut()
577                .ok_or_else(|| invalid(format!("{}: expected a table", section.key())))?;
578            match table.get_mut(name) {
579                Some(item) => *item = replacement(item, value),
580                None => {
581                    table.insert(name, Item::Value(value));
582                }
583            }
584            Ok(())
585        })
586    }
587
588    /// Removes entry `name` from `section`. Returns whether it was there.
589    pub fn remove_entry(&mut self, section: Section, name: &str) -> Result<bool, ChestError> {
590        let mut removed = false;
591        self.edit(|document| {
592            if let Some(table) = document
593                .get_mut(section.key())
594                .and_then(Item::as_table_like_mut)
595            {
596                removed = table.remove(name).is_some();
597            }
598            Ok(())
599        })?;
600        Ok(removed)
601    }
602
603    /// Writes `package` as the `[package]` table, replacing any, as
604    /// `chest init --package` scaffolds it.
605    pub fn set_package(&mut self, package: &Package) -> Result<(), ChestError> {
606        let written = toml_edit::ser::to_document(package)
607            .map_err(|err| invalid(format!("package: {err}")))?;
608        self.edit(|document| {
609            let mut table = written.as_table().clone();
610            match document.get("package") {
611                Some(Item::Table(old)) => {
612                    *table.decor_mut() = old.decor().clone();
613                    table.set_position(old.position());
614                }
615                _ => table.set_position(Some(next_position(document))),
616            }
617            document.insert("package", Item::Table(table));
618            Ok(())
619        })
620    }
621
622    fn edit(
623        &mut self,
624        change: impl FnOnce(&mut DocumentMut) -> Result<(), ChestError>,
625    ) -> Result<(), ChestError> {
626        let mut document = self.document.clone();
627        change(&mut document)?;
628        self.manifest = Manifest::from_document(&document, self.scope)?;
629        self.document = document;
630        Ok(())
631    }
632}
633
634/// The position that shows a new table after every table of `document`.
635/// Without one, toml_edit shows it after the table before it in key order.
636fn next_position(document: &DocumentMut) -> isize {
637    fn last(table: &toml_edit::Table) -> isize {
638        let tables = table.iter().filter_map(|(_, item)| item.as_table());
639        tables
640            .map(|table| table.position().unwrap_or_default().max(last(table)))
641            .max()
642            .unwrap_or_default()
643    }
644    last(document.as_table()) + 1
645}
646
647/// `value` in the form, place and comments of `old`, which it replaces.
648fn replacement(old: &Item, value: toml_edit::Value) -> Item {
649    match (old, value) {
650        (Item::Table(old), toml_edit::Value::InlineTable(inline)) => {
651            let mut table = inline.into_table();
652            *table.decor_mut() = old.decor().clone();
653            table.set_position(old.position());
654            Item::Table(table)
655        }
656        (old, mut value) => {
657            if let Some(old) = old.as_value() {
658                *value.decor_mut() = old.decor().clone();
659            }
660            Item::Value(value)
661        }
662    }
663}
664
665/// The manifest as edited, comments and formatting included.
666impl fmt::Display for ManifestDocument {
667    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
668        self.document.fmt(formatter)
669    }
670}
671
672/// The resource kinds a package can list.
673pub const RESOURCE_KINDS: [&str; 5] = ["extensions", "skills", "prompts", "themes", "agents"];
674
675/// What a package says about itself, from its `chest.toml` `[package]`,
676/// else its `package.json`.
677#[derive(Clone, Debug, Default, PartialEq, Eq)]
678pub struct PackageMeta {
679    /// `[package]` `name`, else package.json `name`.
680    pub name: Option<String>,
681    /// `[package]` `version`, else package.json `version`.
682    pub version: Option<String>,
683    /// `[package]` `description`, else package.json `description`.
684    pub description: Option<String>,
685    /// `[package]` `authors`, else package.json `author`.
686    pub authors: Vec<String>,
687    /// The capabilities requested: `[package]` `capabilities`, else
688    /// package.json `yapi.capabilities`, else none. pi's key never requests
689    /// any.
690    pub capabilities: Vec<String>,
691    /// The event channels it says it uses: `[package]` `events`, else
692    /// package.json `yapi.events`.
693    pub events: Option<EventPolicy>,
694    /// The resource list of each kind of [`RESOURCE_KINDS`] that some source
695    /// lists, taken from the first that lists it: `[package]`, then
696    /// package.json `yapi`, then its `pi` key.
697    pub resources: IndexMap<&'static str, Vec<String>>,
698    /// Whether the lists decide the package's resources, as pi's manifest
699    /// does: a kind none of them lists then provides nothing. True with a
700    /// `pi` key, or with a `[package]` or `yapi` key that lists some kind.
701    /// Otherwise each kind comes from its conventional folder.
702    pub manifest: bool,
703}
704
705/// What the package at `root` says about itself, as [`PackageMeta`]
706/// describes. `Err` when its `chest.toml` does not parse, or its
707/// `[package]` holds what [`Package`] does not. A `chest.toml` that does not
708/// read, and a `package.json` that does not read or parse, count as absent,
709/// as pi treats a `package.json`.
710pub fn package_meta(root: &Path) -> Result<PackageMeta, ChestError> {
711    match read_package_meta(root) {
712        (meta, None) => Ok(meta),
713        (_, Some(err)) => Err(err),
714    }
715}
716
717/// What the package at `root` says about itself, as [`package_meta`], and
718/// why its `chest.toml` was left out when [`package_meta`] fails on it. The
719/// metadata then comes from `package.json` alone.
720pub(crate) fn read_package_meta(root: &Path) -> (PackageMeta, Option<ChestError>) {
721    #[derive(Deserialize)]
722    struct File {
723        package: Option<Package>,
724    }
725    let path = root.join("chest.toml");
726    let (package, problem) = match std::fs::read_to_string(&path) {
727        Ok(text) => match toml_edit::de::from_str::<File>(&text) {
728            Ok(file) => (file.package, None),
729            Err(err) => (None, Some(invalid(format!("{}: {err}", path.display())))),
730        },
731        Err(_) => (None, None),
732    };
733    let json: Value = std::fs::read_to_string(root.join("package.json"))
734        .ok()
735        .and_then(|text| yapi_types::json::parse(&text).ok())
736        .unwrap_or_default();
737    let object = |key: &str| json.get(key).filter(|value| value.is_object());
738    let (yapi, pi) = (object("yapi"), object("pi"));
739    let strings = |value: Option<&Value>, key: &str| -> Option<Vec<String>> {
740        let entries = value?.get(key)?.as_array()?;
741        entries
742            .iter()
743            .map(|entry| entry.as_str().map(str::to_owned))
744            .collect()
745    };
746    let own = package.as_ref();
747    let mut resources = IndexMap::new();
748    let mut listed = false;
749    for kind in RESOURCE_KINDS {
750        let first = own
751            .and_then(|package| package.list(kind).cloned())
752            .or_else(|| strings(yapi, kind));
753        listed |= first.is_some();
754        if let Some(list) = first.or_else(|| strings(pi, kind)) {
755            resources.insert(kind, list);
756        }
757    }
758    let text = |key: &str| json.get(key).and_then(Value::as_str).map(str::to_owned);
759    let field = |pick: fn(&Package) -> &Option<String>, key: &str| {
760        own.and_then(|package| pick(package).clone())
761            .or_else(|| text(key))
762    };
763    let meta = PackageMeta {
764        name: field(|package| &package.name, "name"),
765        version: field(|package| &package.version, "version"),
766        description: field(|package| &package.description, "description"),
767        authors: own
768            .map(|package| package.authors.clone())
769            .filter(|authors| !authors.is_empty())
770            .unwrap_or_else(|| author(json.get("author").unwrap_or(&Value::Null))),
771        capabilities: own
772            .and_then(|package| package.capabilities.clone())
773            .or_else(|| strings(yapi, "capabilities"))
774            .unwrap_or_default(),
775        events: own.and_then(|package| package.events.clone()).or_else(|| {
776            let events = yapi?.get("events")?;
777            EventPolicy::deserialize(events).ok()
778        }),
779        resources,
780        manifest: pi.is_some() || listed,
781    };
782    (meta, problem)
783}
784
785/// npm's `author`: `"Name <email> (url)"` as written, or `{ name, email }`.
786fn author(value: &Value) -> Vec<String> {
787    match value {
788        Value::String(author) => vec![author.clone()],
789        Value::Object(person) => {
790            let name = person.get("name").and_then(Value::as_str);
791            let email = person.get("email").and_then(Value::as_str);
792            name.map(|name| match email {
793                Some(email) => format!("{name} <{email}>"),
794                None => name.to_owned(),
795            })
796            .into_iter()
797            .collect()
798        }
799        _ => Vec::new(),
800    }
801}
802
803#[cfg(test)]
804mod tests {
805    use super::*;
806
807    /// The plan's consumer and publisher example.
808    const EXAMPLE: &str = r#"capabilities-defaults = ["env"]
809
810[folders]                                    # written by `chest init`
811skills = [".agents/skills", ".claude/skills"] # the first folder holds the canonical copy
812agents = [".yapi/agents", ".claude/agents"]
813extensions = ".yapi/chest"                   # extension store, one folder per entry
814
815[skills]
816pdf    = { git = "https://github.com/anthropics/skills", rev = "v1.2.0", path = "skills/pdf" }
817review = { npm = "@acme/agent-skills", version = "^2.1", path = "review" }
818notes  = { path = "tools/skills/notes" }
819
820[agents]
821reviewer = { git = "github.com/acme/agents", rev = "main", path = "reviewer.md" }
822
823[extensions]
824subagent = { npm = "@acme/pi-subagent", version = "^1.4", capabilities = ["all"] }
825todo  = { git = "https://github.com/acme/pi-todo", rev = "4f1c2e9", capabilities = ["fs"], events = { send = ["todo:*"], receive = [] },
826          skills = ["!legacy/**"], prompts = [], themes = ["themes/dark.json"] }
827shout = { path = "ext/shout.wasm" }
828
829[patch]
830todo = { path = "../pi-todo" }
831
832[package]
833name = "acme-review"
834version = "1.3.0"
835description = "Code review skills and a reviewer subagent"
836authors = ["Ada <ada@acme.dev>"]
837license = "MIT"
838repository = "https://github.com/acme/review"
839homepage = "https://acme.dev/review"
840keywords = ["review", "git"]
841capabilities = ["fs", "session"]   # requested, never granted by itself
842events = { send = ["review:*"], receive = ["subagent:done"] }   # requested channel links
843skills = ["skills/*"]              # globs. Absent: conventional folders
844agents = ["agents/*.md"]
845extensions = ["extensions/index.ts"]
846"#;
847
848    fn strings(values: &[&str]) -> Vec<String> {
849        values.iter().map(|value| (*value).to_owned()).collect()
850    }
851
852    fn some(values: &[&str]) -> Option<Vec<String>> {
853        Some(strings(values))
854    }
855
856    #[test]
857    fn manifest_parses_example() {
858        let manifest = Manifest::parse(EXAMPLE, Scope::Project).unwrap();
859        assert_eq!(manifest.capabilities_defaults, ["env"]);
860        assert_eq!(
861            manifest.folders,
862            Folders {
863                skills: strings(&[".agents/skills", ".claude/skills"]),
864                agents: strings(&[".yapi/agents", ".claude/agents"]),
865                extensions: Some(".yapi/chest".into()),
866            }
867        );
868        let names = |section: Section| -> Vec<&str> {
869            manifest
870                .section(section)
871                .keys()
872                .map(String::as_str)
873                .collect()
874        };
875        assert_eq!(names(Section::Skills), ["pdf", "review", "notes"]);
876        assert_eq!(names(Section::Agents), ["reviewer"]);
877        assert_eq!(names(Section::Extensions), ["subagent", "todo", "shout"]);
878        assert_eq!(
879            manifest.skills["review"],
880            Entry {
881                npm: Some("@acme/agent-skills".into()),
882                version: Some("^2.1".into()),
883                path: Some("review".into()),
884                ..Entry::default()
885            }
886        );
887        assert_eq!(
888            manifest.extensions["todo"],
889            Entry {
890                git: Some("https://github.com/acme/pi-todo".into()),
891                rev: Some("4f1c2e9".into()),
892                capabilities: some(&["fs"]),
893                events: Some(EventPolicy::new(strings(&["todo:*"]), Vec::new()).unwrap()),
894                skills: some(&["!legacy/**"]),
895                prompts: some(&[]),
896                themes: some(&["themes/dark.json"]),
897                ..Entry::default()
898            }
899        );
900        assert_eq!(manifest.patch["todo"].path.as_deref(), Some("../pi-todo"));
901        let package = manifest.package.unwrap();
902        assert_eq!(package.name.as_deref(), Some("acme-review"));
903        assert_eq!(package.authors, ["Ada <ada@acme.dev>"]);
904        assert_eq!(package.keywords, ["review", "git"]);
905        assert_eq!(package.capabilities, some(&["fs", "session"]));
906        assert_eq!(
907            package.events,
908            Some(EventPolicy::new(strings(&["review:*"]), strings(&["subagent:done"])).unwrap())
909        );
910        assert_eq!(package.agents, some(&["agents/*.md"]));
911
912        let rejected = [
913            (
914                "[skills]\nPdf = { path = \"x\" }",
915                "skills.Pdf: names use lowercase letters, digits, `.`, `_` and `-`, and start with a letter or digit",
916            ),
917            (
918                "[skills]\n-x = { path = \"x\" }",
919                "skills.-x: names use lowercase letters, digits, `.`, `_` and `-`, and start with a letter or digit",
920            ),
921            (
922                "[skills]\nx = { path = \"a\" }\n[agents]\nx = { path = \"b\" }",
923                "agents.x: the name is already used in [skills]",
924            ),
925            (
926                "[skills]\nx = { git = \"https://github.com/a/b\", npm = \"c\" }",
927                "skills.x: `git` and `npm` cannot be combined",
928            ),
929            ("[skills]\nx = {}", "skills.x: needs `git`, `npm` or `path`"),
930            (
931                "[skills]\nx = { path = \"a\", rev = \"v1\" }",
932                "skills.x: `rev` needs `git`",
933            ),
934            (
935                "[skills]\nx = { git = \"https://github.com/a/b\", version = \"1\" }",
936                "skills.x: `version` needs `npm`",
937            ),
938            (
939                "[skills]\nx = { git = \"a b\" }",
940                "skills.x: `git`: not a git repository: a b",
941            ),
942            (
943                "[skills]\nx = { path = 1 }",
944                "skills.x: `path`: invalid type: integer `1`, expected a string",
945            ),
946            (
947                "[skills]\nx = { path = \"a\", capabilities = [\"fs\"] }",
948                "skills.x: `capabilities` belongs to [extensions] entries",
949            ),
950            (
951                "[agents]\nx = { path = \"a\", events = \"isolated\" }",
952                "agents.x: `events` belongs to [extensions] entries",
953            ),
954            (
955                "[extensions]\nx = { npm = \"a\", path = \"b\" }",
956                "extensions.x: `path` cannot be combined with `npm` for an extension, which loads its whole npm package",
957            ),
958            (
959                "[extensions]\nx = { path = \"a\", capabilities = [\"process\"] }",
960                "extensions.x: `capabilities`: \"process\" is not a capability: a process runs as you, outside the sandbox, so only \"all\" allows it",
961            ),
962            (
963                "[extensions]\nx = { path = \"a\", capabilities = [\"none\", \"fs\"] }",
964                "extensions.x: `capabilities`: \"none\" cannot be combined with other capabilities",
965            ),
966            (
967                "[extensions]\nx = { path = \"a\", events = \"open\" }",
968                "extensions.x: `events`: invalid value: string \"open\", expected \"shared\", \"isolated\" or { send, receive }",
969            ),
970            (
971                "[extensions]\nx = { path = \"a\", events = { send = [\"a*b\"] } }",
972                "extensions.x: `events`: invalid event channel pattern \"a*b\": use a channel name, a prefix ending in `*`, or `*`",
973            ),
974            (
975                "[patch]\nx = { git = \"https://github.com/a/b\" }",
976                "patch.x: a patch takes `path` only",
977            ),
978            (
979                "capabilities-defaults = [\"exec\"]",
980                "capabilities-defaults: unknown capability \"exec\"",
981            ),
982            (
983                "[package]\ncapabilities = [\"none\", \"net\"]",
984                "package: `capabilities`: \"none\" cannot be combined with other capabilities",
985            ),
986            ("colour = 1", "unknown key `colour`"),
987            (
988                "[folders]\nskills = [\".agents/skills\", \"../x\"]",
989                "folders.skills: \"../x\" must be a relative path inside the project, without `..` or `~`",
990            ),
991            (
992                "[folders]\nagents = [\"/abs\"]",
993                "folders.agents: \"/abs\" must be a relative path inside the project, without `..` or `~`",
994            ),
995            (
996                "[folders]\nextensions = \"~/x\"",
997                "folders.extensions: \"~/x\" must be a relative path inside the project, without `..` or `~`",
998            ),
999            (
1000                "[skills]\nx = { path = \"~/x\" }",
1001                "skills.x: `path`: \"~/x\" must be relative to the manifest, not absolute or under `~`",
1002            ),
1003            (
1004                "[patch]\nx = { path = \"/abs\" }",
1005                "patch.x: `path`: \"/abs\" must be relative to the manifest, not absolute or under `~`",
1006            ),
1007            (
1008                "[skills]\nx = { git = \"https://github.com/a/b\", path = \"../x\" }",
1009                "skills.x: `path`: \"../x\" must be a folder inside the package",
1010            ),
1011            (
1012                "[extensions]\nx = { path = \".\" }",
1013                "extensions.x: `path`: \".\" must name the extension package, not a folder that holds the manifest",
1014            ),
1015            (
1016                "[extensions]\nx = { path = \"../..\" }",
1017                "extensions.x: `path`: \"../..\" must name the extension package, not a folder that holds the manifest",
1018            ),
1019            (
1020                "[skills]\nx = { git = \"https://github.com/a/b\", rev = \"--upload-pack=x\" }",
1021                "skills.x: `rev` must not start with `-`",
1022            ),
1023            ("skills = 1", "skills: expected a table"),
1024            (
1025                "[folders]\nskills = [\".\"]",
1026                "folders.skills: \".\" must name a folder",
1027            ),
1028            (
1029                "[folders]\nskills = [\".agents\", \".agents/skills\"]",
1030                "folders: \".agents\" and \".agents/skills\" overlap",
1031            ),
1032            (
1033                "[folders]\nagents = [\".yapi/chest/agents\"]",
1034                "folders: \".yapi/chest/agents\" and \".yapi/chest\" overlap",
1035            ),
1036            (
1037                "[skills]\nx = { git = \"github.com/a/b@v1\" }",
1038                "skills.x: `git`: \"github.com/a/b@v1\" names the revision `v1`: put it in `rev`",
1039            ),
1040        ];
1041        for (text, message) in rejected {
1042            let error = Manifest::parse(text, Scope::Project)
1043                .unwrap_err()
1044                .to_string();
1045            assert_eq!(error, message, "{text}");
1046        }
1047        // The user's own global manifest may name folders anywhere.
1048        let global = "[folders]\nskills = [\"~/x\", \"/abs\"]\n[skills]\nx = { path = \"~/x\" }";
1049        let global = Manifest::parse(global, Scope::Global).unwrap();
1050        assert_eq!(global.folders.skills, ["~/x", "/abs"]);
1051        // Unknown keys name the entry and the key.
1052        for (text, start) in [
1053            (
1054                "[skills]\nx = { path = \"a\", foo = 1 }",
1055                "skills.x: unknown field `foo`",
1056            ),
1057            ("[folders]\nskill = []", "folders: unknown field `skill`"),
1058            (
1059                "[package]\nauthor = \"Ada\"",
1060                "package: unknown field `author`",
1061            ),
1062            (
1063                "[extensions]\nx = { path = \"a\", events = { sent = [] } }",
1064                "extensions.x: `events`: unknown field `sent`",
1065            ),
1066        ] {
1067            let error = Manifest::parse(text, Scope::Project)
1068                .unwrap_err()
1069                .to_string();
1070            assert!(error.starts_with(start), "{error}");
1071        }
1072    }
1073
1074    #[test]
1075    fn capabilities_union_defaults() {
1076        let manifest = Manifest::parse(
1077            "capabilities-defaults = [\"env\"]\n[extensions]\nfs = { path = \"a\", capabilities = [\"fs\"] }\nplain = { path = \"b\" }\nall = { path = \"c\", capabilities = [\"all\"] }\n",
1078            Scope::Project,
1079        )
1080        .unwrap();
1081        let grants = |name: &str| manifest.grants(&manifest.extensions[name]);
1082        let env = Grants {
1083            environment: true,
1084            ..Grants::none()
1085        };
1086        assert_eq!(
1087            grants("fs"),
1088            Grants {
1089                filesystem: true,
1090                ..env
1091            }
1092        );
1093        assert_eq!(grants("plain"), env);
1094        assert_eq!(grants("all"), Grants::default());
1095        let bare =
1096            Manifest::parse("[extensions]\nplain = { path = \"b\" }\n", Scope::Project).unwrap();
1097        assert_eq!(bare.grants(&bare.extensions["plain"]), Grants::none());
1098    }
1099
1100    #[test]
1101    fn edit_preserves_format() {
1102        let text = r#"# Project packages.
1103capabilities-defaults = ["env"]
1104
1105[skills]
1106# The PDF skill.
1107pdf    = { git = "https://github.com/anthropics/skills", rev = "v1.2.0", path = "skills/pdf" } # pinned
1108notes  = { path = "tools/skills/notes" }
1109
1110# Long entries can be tables.
1111[extensions.todo]
1112git = "https://github.com/acme/pi-todo"
1113capabilities = ["fs"]
1114
1115[extensions]
1116shout = { path = "ext/shout.wasm" }
1117"#;
1118        let mut document = ManifestDocument::parse(text, Scope::Project).unwrap();
1119        let pdf = Entry {
1120            rev: Some("v1.3.0".into()),
1121            ..document.manifest().skills["pdf"].clone()
1122        };
1123        document.set_entry(Section::Skills, "pdf", &pdf).unwrap();
1124        let todo = Entry {
1125            capabilities: Some(strings(&["fs", "net"])),
1126            ..document.manifest().extensions["todo"].clone()
1127        };
1128        document
1129            .set_entry(Section::Extensions, "todo", &todo)
1130            .unwrap();
1131        let reviewer = Entry {
1132            git: Some("github.com/acme/agents".into()),
1133            path: Some("reviewer.md".into()),
1134            ..Entry::default()
1135        };
1136        document
1137            .set_entry(Section::Agents, "reviewer", &reviewer)
1138            .unwrap();
1139        assert!(document.remove_entry(Section::Skills, "notes").unwrap());
1140        assert!(!document.remove_entry(Section::Skills, "gone").unwrap());
1141        // An edit that would not validate changes nothing.
1142        let before = document.to_string();
1143        assert!(
1144            document
1145                .set_entry(Section::Agents, "Bad", &reviewer)
1146                .is_err()
1147        );
1148        assert!(
1149            document
1150                .set_entry(Section::Agents, "pdf", &reviewer)
1151                .is_err()
1152        );
1153        assert_eq!(document.to_string(), before);
1154        document
1155            .set_package(&Package {
1156                name: Some("acme".into()),
1157                capabilities: Some(Vec::new()),
1158                ..Package::default()
1159            })
1160            .unwrap();
1161        assert_eq!(
1162            document.to_string(),
1163            r#"# Project packages.
1164capabilities-defaults = ["env"]
1165
1166[skills]
1167# The PDF skill.
1168pdf    = { git = "https://github.com/anthropics/skills", rev = "v1.3.0", path = "skills/pdf" } # pinned
1169
1170# Long entries can be tables.
1171[extensions.todo]
1172git = "https://github.com/acme/pi-todo"
1173capabilities = ["fs", "net"]
1174
1175[extensions]
1176shout = { path = "ext/shout.wasm" }
1177
1178[agents]
1179reviewer = { git = "github.com/acme/agents", path = "reviewer.md" }
1180
1181[package]
1182name = "acme"
1183capabilities = []
1184"#
1185        );
1186        let manifest = document.manifest();
1187        assert_eq!(manifest.skills["pdf"].rev.as_deref(), Some("v1.3.0"));
1188        assert_eq!(manifest.agents["reviewer"], reviewer);
1189        assert_eq!(
1190            manifest.package.as_ref().and_then(|p| p.name.as_deref()),
1191            Some("acme")
1192        );
1193        assert_eq!(
1194            Manifest::parse(&document.to_string(), Scope::Project)
1195                .as_ref()
1196                .ok(),
1197            Some(manifest)
1198        );
1199    }
1200
1201    #[test]
1202    fn metadata_precedence() {
1203        let dir = std::env::temp_dir().join(format!("yapi-chest-meta-{}", std::process::id()));
1204        let _ = std::fs::remove_dir_all(&dir);
1205        std::fs::create_dir_all(&dir).unwrap();
1206        let write = |name: &str, text: &str| std::fs::write(dir.join(name), text).unwrap();
1207
1208        // pi gives resources, never capabilities.
1209        write(
1210            "package.json",
1211            r#"{"name": "pkg", "version": "1.0.0", "description": "From npm", "author": {"name": "Ada", "email": "ada@acme.dev"},
1212                "pi": {"extensions": ["./pi.ts"], "skills": ["pi-skills"], "capabilities": ["all"]}}"#,
1213        );
1214        let meta = package_meta(&dir).unwrap();
1215        assert_eq!(meta.name.as_deref(), Some("pkg"));
1216        assert_eq!(meta.authors, ["Ada <ada@acme.dev>"]);
1217        assert!(meta.capabilities.is_empty());
1218        assert_eq!(meta.resources["extensions"], ["./pi.ts"]);
1219        assert!(meta.manifest);
1220
1221        // Per kind: [package], then `yapi`, then `pi`.
1222        write(
1223            "package.json",
1224            r#"{"name": "pkg", "version": "1.0.0", "description": "From npm", "author": "Ada",
1225                "yapi": {"capabilities": ["net"], "skills": ["yapi-skills"], "events": {"send": ["pkg:*"]}},
1226                "pi": {"extensions": ["./pi.ts"], "skills": ["pi-skills"], "prompts": ["pi-prompts"]}}"#,
1227        );
1228        write(
1229            "chest.toml",
1230            "[skills]\nignored = { path = \"x\" }\n[package]\ndescription = \"From chest\"\nprompts = [\"chest-prompts\"]\nagents = [\"agents/*.md\"]\n",
1231        );
1232        let meta = package_meta(&dir).unwrap();
1233        assert_eq!(meta.name.as_deref(), Some("pkg"));
1234        assert_eq!(meta.version.as_deref(), Some("1.0.0"));
1235        assert_eq!(meta.description.as_deref(), Some("From chest"));
1236        assert_eq!(meta.authors, ["Ada"]);
1237        assert_eq!(meta.capabilities, ["net"]);
1238        assert_eq!(
1239            meta.events,
1240            Some(EventPolicy::new(strings(&["pkg:*"]), Vec::new()).unwrap())
1241        );
1242        let lists: Vec<(&str, Vec<String>)> = meta.resources.clone().into_iter().collect();
1243        assert_eq!(
1244            lists,
1245            [
1246                ("extensions", strings(&["./pi.ts"])),
1247                ("skills", strings(&["yapi-skills"])),
1248                ("prompts", strings(&["chest-prompts"])),
1249                ("agents", strings(&["agents/*.md"])),
1250            ]
1251        );
1252        write(
1253            "chest.toml",
1254            "[package]\ncapabilities = []\nauthors = [\"Bo\"]\n",
1255        );
1256        let meta = package_meta(&dir).unwrap();
1257        assert!(meta.capabilities.is_empty());
1258        assert_eq!(meta.authors, ["Bo"]);
1259
1260        // Lists only of capabilities decide nothing, so the folders apply.
1261        std::fs::remove_file(dir.join("chest.toml")).unwrap();
1262        write("package.json", r#"{"yapi": {"capabilities": ["fs"]}}"#);
1263        let meta = package_meta(&dir).unwrap();
1264        assert_eq!(meta.capabilities, ["fs"]);
1265        assert!(meta.resources.is_empty() && !meta.manifest);
1266        // A broken [package] is an error that names the file.
1267        write(
1268            "chest.toml",
1269            "[package]\nauthor = \"Ada\"\nskills = [\"s\"]\n",
1270        );
1271        let broken = package_meta(&dir).unwrap_err().to_string();
1272        let file = dir.join("chest.toml").display().to_string();
1273        assert!(broken.starts_with(&file), "{broken}");
1274        assert!(broken.contains("unknown field `author`"), "{broken}");
1275        std::fs::remove_dir_all(&dir).unwrap();
1276        assert_eq!(package_meta(&dir).unwrap(), PackageMeta::default());
1277    }
1278}