Skip to main content

pakx_core/manifest/
schema.rs

1//! Strongly-typed representation of `agents.yml`.
2//!
3//! The manifest is the single source of truth for what gets installed across
4//! every detected agent. Schema mirrors the master prompt spec verbatim.
5
6use std::borrow::Cow;
7use std::sync::LazyLock;
8
9use regex::Regex;
10use serde::{Deserialize, Serialize};
11
12/// Known package types installable across agents. Used as keys in
13/// `dependencies` (YAML) and as the `type` discriminator in lockfile entries.
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
15#[serde(rename_all = "lowercase")]
16pub enum PackageType {
17    Skills,
18    Mcp,
19    Subagents,
20    Prompts,
21    Commands,
22    Hooks,
23}
24
25impl PackageType {
26    pub const fn as_str(self) -> &'static str {
27        match self {
28            Self::Skills => "skills",
29            Self::Mcp => "mcp",
30            Self::Subagents => "subagents",
31            Self::Prompts => "prompts",
32            Self::Commands => "commands",
33            Self::Hooks => "hooks",
34        }
35    }
36}
37
38/// All package type variants in canonical order. Use this for deterministic
39/// iteration in writers, doctor reports, etc.
40pub const PACKAGE_TYPES: [PackageType; 6] = [
41    PackageType::Skills,
42    PackageType::Mcp,
43    PackageType::Subagents,
44    PackageType::Prompts,
45    PackageType::Commands,
46    PackageType::Hooks,
47];
48
49/// Known built-in agent ids. The wire type is still `String`/`AgentId`, so
50/// adding a new adapter requires no schema bump.
51pub const KNOWN_AGENT_IDS: &[&str] = &["claude-code", "cursor", "codex", "copilot", "windsurf"];
52
53static AGENT_ID_RE: LazyLock<Regex> =
54    LazyLock::new(|| Regex::new(r"^[a-z][a-z0-9-]*$").expect("static regex compiles"));
55
56/// Validated agent identifier (lowercase kebab-case).
57#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize)]
58#[serde(transparent)]
59pub struct AgentId(String);
60
61impl AgentId {
62    /// Construct without validation. Intended for trusted inputs (constants,
63    /// adapter self-registration). Untrusted strings should go through
64    /// [`AgentId::parse`].
65    pub fn new_unchecked(s: impl Into<String>) -> Self {
66        Self(s.into())
67    }
68
69    /// Validate and wrap an agent id string.
70    pub fn parse(s: impl Into<String>) -> Result<Self, String> {
71        let s = s.into();
72        if AGENT_ID_RE.is_match(&s) {
73            Ok(Self(s))
74        } else {
75            Err(format!(
76                "invalid agent id {s:?}: must be lowercase kebab-case starting with a letter"
77            ))
78        }
79    }
80
81    pub const fn as_str(&self) -> &str {
82        self.0.as_str()
83    }
84}
85
86impl<'de> Deserialize<'de> for AgentId {
87    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
88    where
89        D: serde::Deserializer<'de>,
90    {
91        let s = String::deserialize(deserializer)?;
92        Self::parse(s).map_err(serde::de::Error::custom)
93    }
94}
95
96// ---------------------------------------------------------------------------
97// Dependency specs
98// ---------------------------------------------------------------------------
99
100/// Shorthand id string like `owner/name@^1.0` or `acme/skill`.
101/// Validated at deserialization: no whitespace, non-empty.
102#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
103#[serde(transparent)]
104pub struct StringSpec(String);
105
106impl StringSpec {
107    pub fn parse(s: impl Into<String>) -> Result<Self, String> {
108        let s = s.into();
109        if s.is_empty() {
110            return Err("dep shorthand must not be empty".into());
111        }
112        if s.chars().any(char::is_whitespace) {
113            return Err(format!(
114                "dep shorthand {s:?} contains whitespace; use the object form for git/registry"
115            ));
116        }
117        Ok(Self(s))
118    }
119
120    pub const fn as_str(&self) -> &str {
121        self.0.as_str()
122    }
123}
124
125impl<'de> Deserialize<'de> for StringSpec {
126    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
127    where
128        D: serde::Deserializer<'de>,
129    {
130        let s = String::deserialize(deserializer)?;
131        Self::parse(s).map_err(serde::de::Error::custom)
132    }
133}
134
135/// Git-sourced dep: `{ git: "https://...", ref: "v1.3.0" }`.
136#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
137#[serde(deny_unknown_fields)]
138pub struct GitSpec {
139    pub git: String,
140    #[serde(rename = "ref", default, skip_serializing_if = "Option::is_none")]
141    pub git_ref: Option<String>,
142    #[serde(default, skip_serializing_if = "Option::is_none")]
143    pub subpath: Option<String>,
144}
145
146/// Registry-explicit dep: `{ registry: "official", name: "filesystem", args: [...] }`.
147#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
148#[serde(deny_unknown_fields)]
149pub struct RegistrySpec {
150    pub registry: String,
151    pub name: String,
152    #[serde(default, skip_serializing_if = "Option::is_none")]
153    pub version: Option<String>,
154    #[serde(default, skip_serializing_if = "Option::is_none")]
155    pub args: Option<Vec<String>>,
156}
157
158/// A single dep entry inside a `dependencies.<type>` list. Accepts all three
159/// forms from the spec: bare string, git object, or registry object.
160#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
161#[serde(untagged)]
162pub enum DepSpec {
163    String(StringSpec),
164    Git(GitSpec),
165    Registry(RegistrySpec),
166}
167
168// ---------------------------------------------------------------------------
169// Dependencies + Manifest
170// ---------------------------------------------------------------------------
171
172/// All declared dependencies grouped by package type. Every field is
173/// optional in the YAML source; missing fields deserialize to `None`.
174/// Empty arrays are skipped on serialization.
175#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
176#[serde(deny_unknown_fields)]
177pub struct Dependencies {
178    #[serde(default, skip_serializing_if = "Option::is_none")]
179    pub skills: Option<Vec<DepSpec>>,
180    #[serde(default, skip_serializing_if = "Option::is_none")]
181    pub mcp: Option<Vec<DepSpec>>,
182    #[serde(default, skip_serializing_if = "Option::is_none")]
183    pub subagents: Option<Vec<DepSpec>>,
184    #[serde(default, skip_serializing_if = "Option::is_none")]
185    pub prompts: Option<Vec<DepSpec>>,
186    #[serde(default, skip_serializing_if = "Option::is_none")]
187    pub commands: Option<Vec<DepSpec>>,
188    #[serde(default, skip_serializing_if = "Option::is_none")]
189    pub hooks: Option<Vec<DepSpec>>,
190}
191
192impl Dependencies {
193    pub const fn get(&self, kind: PackageType) -> Option<&Vec<DepSpec>> {
194        match kind {
195            PackageType::Skills => self.skills.as_ref(),
196            PackageType::Mcp => self.mcp.as_ref(),
197            PackageType::Subagents => self.subagents.as_ref(),
198            PackageType::Prompts => self.prompts.as_ref(),
199            PackageType::Commands => self.commands.as_ref(),
200            PackageType::Hooks => self.hooks.as_ref(),
201        }
202    }
203}
204
205/// Project-level manifest persisted as `agents.yml`. Field order here is
206/// also the canonical write order.
207#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
208#[serde(deny_unknown_fields)]
209pub struct Manifest {
210    pub name: String,
211    pub version: String,
212    #[serde(default, skip_serializing_if = "Option::is_none")]
213    pub agents: Option<Vec<AgentId>>,
214    #[serde(default, skip_serializing_if = "is_empty_dependencies")]
215    pub dependencies: Dependencies,
216}
217
218// ---------------------------------------------------------------------------
219// Sponsor links (Phase X2b — see pakx-registry/SPONSOR_LINKS_SPEC.md)
220// ---------------------------------------------------------------------------
221
222/// Sponsor-link kind whitelist. Locked at four variants per the cross-repo
223/// spec; adding a new kind is additive across CLI / registry / web.
224#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
225#[serde(rename_all = "lowercase")]
226pub enum SponsorKind {
227    Github,
228    Polar,
229    Kofi,
230    Url,
231}
232
233impl SponsorKind {
234    pub const fn as_str(self) -> &'static str {
235        match self {
236            Self::Github => "github",
237            Self::Polar => "polar",
238            Self::Kofi => "kofi",
239            Self::Url => "url",
240        }
241    }
242}
243
244/// A single sponsor link from `SKILL.md` frontmatter.
245///
246/// The full validation pipeline (per-kind URL regex, max-count cap,
247/// https-only) lives in [`crate::manifest::sponsors::validate_sponsors`];
248/// this struct only guards the wire shape (`kind`, `url`).
249#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
250#[serde(deny_unknown_fields)]
251pub struct Sponsor {
252    pub kind: SponsorKind,
253    pub url: String,
254}
255
256const fn is_empty_dependencies(d: &Dependencies) -> bool {
257    d.skills.is_none()
258        && d.mcp.is_none()
259        && d.subagents.is_none()
260        && d.prompts.is_none()
261        && d.commands.is_none()
262        && d.hooks.is_none()
263}
264
265// ---------------------------------------------------------------------------
266// Helpers
267// ---------------------------------------------------------------------------
268
269impl DepSpec {
270    /// Convenience: the textual "name" hint a user would recognise, for
271    /// log lines / conflict messages. Not a stable identity.
272    pub fn display_hint(&self) -> Cow<'_, str> {
273        match self {
274            Self::String(s) => Cow::Borrowed(s.as_str()),
275            Self::Git(g) => Cow::Owned(format!("git:{}", g.git)),
276            Self::Registry(r) => Cow::Owned(format!("{}/{}", r.registry, r.name)),
277        }
278    }
279}