1use std::borrow::Cow;
7use std::sync::LazyLock;
8
9use regex::Regex;
10use serde::{Deserialize, Serialize};
11
12#[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
38pub 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
49pub 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#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize)]
58#[serde(transparent)]
59pub struct AgentId(String);
60
61impl AgentId {
62 pub fn new_unchecked(s: impl Into<String>) -> Self {
66 Self(s.into())
67 }
68
69 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#[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#[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#[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#[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#[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#[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#[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#[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
265impl DepSpec {
270 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}