1use 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#[derive(Clone, Copy, Debug, PartialEq, Eq)]
23pub enum Section {
24 Skills,
26 Agents,
28 Extensions,
30 Patch,
32}
33
34impl Section {
35 pub const ALL: [Section; 4] = [
37 Section::Skills,
38 Section::Agents,
39 Section::Extensions,
40 Section::Patch,
41 ];
42
43 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#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
56#[serde(deny_unknown_fields)]
57pub struct Folders {
58 #[serde(default, skip_serializing_if = "Vec::is_empty")]
61 pub skills: Vec<String>,
62 #[serde(default, skip_serializing_if = "Vec::is_empty")]
64 pub agents: Vec<String>,
65 #[serde(default, skip_serializing_if = "Option::is_none")]
67 pub extensions: Option<String>,
68}
69
70#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
74#[serde(deny_unknown_fields)]
75pub struct Entry {
76 #[serde(default, skip_serializing_if = "Option::is_none")]
78 pub git: Option<String>,
79 #[serde(default, skip_serializing_if = "Option::is_none")]
81 pub rev: Option<String>,
82 #[serde(default, skip_serializing_if = "Option::is_none")]
84 pub npm: Option<String>,
85 #[serde(default, skip_serializing_if = "Option::is_none")]
87 pub version: Option<String>,
88 #[serde(default, skip_serializing_if = "Option::is_none")]
90 pub path: Option<String>,
91 #[serde(default, skip_serializing_if = "Option::is_none")]
94 pub capabilities: Option<Vec<String>>,
95 #[serde(default, skip_serializing_if = "Option::is_none")]
98 pub events: Option<EventPolicy>,
99 #[serde(default, skip_serializing_if = "Option::is_none")]
101 pub skills: Option<Vec<String>>,
102 #[serde(default, skip_serializing_if = "Option::is_none")]
104 pub prompts: Option<Vec<String>>,
105 #[serde(default, skip_serializing_if = "Option::is_none")]
107 pub themes: Option<Vec<String>>,
108}
109
110#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
115#[serde(deny_unknown_fields)]
116pub struct Package {
117 #[serde(default, skip_serializing_if = "Option::is_none")]
119 pub name: Option<String>,
120 #[serde(default, skip_serializing_if = "Option::is_none")]
122 pub version: Option<String>,
123 #[serde(default, skip_serializing_if = "Option::is_none")]
125 pub description: Option<String>,
126 #[serde(default, skip_serializing_if = "Vec::is_empty")]
128 pub authors: Vec<String>,
129 #[serde(default, skip_serializing_if = "Option::is_none")]
131 pub license: Option<String>,
132 #[serde(default, skip_serializing_if = "Option::is_none")]
134 pub repository: Option<String>,
135 #[serde(default, skip_serializing_if = "Option::is_none")]
137 pub homepage: Option<String>,
138 #[serde(default, skip_serializing_if = "Vec::is_empty")]
140 pub keywords: Vec<String>,
141 #[serde(default, skip_serializing_if = "Option::is_none")]
143 pub capabilities: Option<Vec<String>>,
144 #[serde(default, skip_serializing_if = "Option::is_none")]
146 pub events: Option<EventPolicy>,
147 #[serde(default, skip_serializing_if = "Option::is_none")]
149 pub skills: Option<Vec<String>>,
150 #[serde(default, skip_serializing_if = "Option::is_none")]
152 pub agents: Option<Vec<String>>,
153 #[serde(default, skip_serializing_if = "Option::is_none")]
155 pub extensions: Option<Vec<String>>,
156 #[serde(default, skip_serializing_if = "Option::is_none")]
158 pub prompts: Option<Vec<String>>,
159 #[serde(default, skip_serializing_if = "Option::is_none")]
161 pub themes: Option<Vec<String>>,
162}
163
164impl Package {
165 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#[derive(Clone, Debug, Default, PartialEq, Eq)]
181pub struct Manifest {
182 pub capabilities_defaults: Vec<String>,
184 pub folders: Folders,
186 pub skills: IndexMap<String, Entry>,
188 pub agents: IndexMap<String, Entry>,
190 pub extensions: IndexMap<String, Entry>,
192 pub patch: IndexMap<String, Entry>,
194 pub package: Option<Package>,
196}
197
198impl Manifest {
199 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 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 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 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 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 (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
457fn 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
470pub 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
483pub const MANIFEST_ENV: &str = "CHEST_MANIFEST";
486
487pub 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
496pub 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
519pub 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
528pub(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
550fn 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 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#[derive(Clone, Debug)]
581pub struct ManifestDocument {
582 document: DocumentMut,
583 manifest: Manifest,
584 scope: Scope,
585}
586
587impl ManifestDocument {
588 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 pub fn manifest(&self) -> &Manifest {
604 &self.manifest
605 }
606
607 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 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 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
687fn 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
700fn 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
718impl fmt::Display for ManifestDocument {
720 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
721 self.document.fmt(formatter)
722 }
723}
724
725pub const RESOURCE_KINDS: [&str; 5] = ["extensions", "skills", "prompts", "themes", "agents"];
727
728#[derive(Clone, Debug, Default, PartialEq, Eq)]
731pub struct PackageMeta {
732 pub name: Option<String>,
734 pub version: Option<String>,
736 pub description: Option<String>,
738 pub authors: Vec<String>,
740 pub capabilities: Vec<String>,
744 pub events: Option<EventPolicy>,
747 pub resources: IndexMap<&'static str, Vec<String>>,
751 pub manifest: bool,
756}
757
758pub 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
770pub(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
838fn 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 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 (
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 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 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 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 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 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 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 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}