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