1use serde::{Deserialize, Serialize};
6
7#[derive(Debug, Clone, Serialize, Deserialize)]
9pub struct MuragentManifest {
10 pub schema: String,
11 pub exported_at: String,
12 pub exporter: ExporterInfo,
13 pub agent: AgentRef,
14 pub required_surfaces: Vec<Surface>,
15 #[serde(default)]
16 pub optional_capabilities: Vec<String>,
17 #[serde(default)]
18 pub mcp_servers: Vec<McpServerRef>,
19 pub icon: IconHashes,
20 #[serde(default)]
21 pub sanitized: SanitizedReport,
22 #[serde(skip_serializing_if = "Option::is_none")]
23 pub hub: Option<HubBlock>,
24 #[serde(skip_serializing_if = "Option::is_none")]
25 pub commander: Option<CommanderBlock>,
26 #[serde(skip_serializing_if = "Option::is_none")]
28 pub deployment: Option<serde_json::Value>,
29 #[serde(skip_serializing_if = "Option::is_none")]
31 pub assignment: Option<serde_json::Value>,
32 #[serde(default, skip_serializing_if = "Option::is_none")]
34 pub model_hint: Option<ModelHint>,
35 #[serde(default, skip_serializing_if = "Option::is_none")]
37 pub model_requirements: Option<ModelRequirements>,
38 #[serde(default, skip_serializing_if = "Option::is_none")]
43 pub distribution: Option<String>,
44}
45
46#[derive(Debug, Clone, Serialize, Deserialize)]
47pub struct ExporterInfo {
48 pub mur_version: String,
49 pub tool: String,
50 #[serde(skip_serializing_if = "Option::is_none")]
51 pub min_hub_version: Option<String>,
52 #[serde(skip_serializing_if = "Option::is_none")]
53 pub min_commander_version: Option<String>,
54}
55
56#[derive(Debug, Clone, Serialize, Deserialize)]
57pub struct AgentRef {
58 pub slug: String,
59 pub display_name: String,
60 pub bundle_id: String,
61 pub url_scheme: String,
62 pub original_uuid: String,
63}
64
65#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
66#[serde(rename_all = "snake_case")]
67pub enum ModelTier {
68 Small,
69 Mid,
70 Frontier,
71}
72
73#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
77pub struct ModelRequirements {
78 #[serde(default)]
79 pub chat: bool,
80 #[serde(default)]
81 pub tools: bool,
82 #[serde(default, skip_serializing_if = "Option::is_none")]
83 pub minimum_context_window: Option<u64>,
84}
85
86#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
87pub struct ModelHint {
88 pub provider: String,
89 pub name: String,
90 pub tier: ModelTier,
91 #[serde(default)]
92 pub min_ram_gb: u32,
93 pub local_capable: bool,
94}
95
96#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
97#[serde(rename_all = "snake_case")]
98pub enum Surface {
99 Hub,
100 Commander,
101}
102
103#[derive(Debug, Clone, Serialize, Deserialize)]
104pub struct McpServerRef {
105 pub name: String,
106 pub command_basename: String,
107}
108
109#[derive(Debug, Clone, Serialize, Deserialize)]
110pub struct IconHashes {
111 #[serde(default)]
112 pub formats: Vec<String>,
113 #[serde(default)]
114 pub hash: IconHashMap,
115}
116
117#[derive(Debug, Clone, Default, Serialize, Deserialize)]
118pub struct IconHashMap {
119 #[serde(skip_serializing_if = "Option::is_none")]
120 pub icns: Option<String>,
121 #[serde(skip_serializing_if = "Option::is_none")]
122 pub ico: Option<String>,
123 #[serde(skip_serializing_if = "Option::is_none")]
124 pub png: Option<String>,
125}
126
127#[derive(Debug, Clone, Default, Serialize, Deserialize)]
128pub struct SanitizedReport {
129 #[serde(default)]
130 pub removed_fields: Vec<String>,
131}
132
133#[derive(Debug, Clone, Serialize, Deserialize)]
136pub struct HubBlock {
137 pub appearance: HubAppearance,
138 #[serde(skip_serializing_if = "Option::is_none")]
139 pub voice: Option<HubVoice>,
140 #[serde(skip_serializing_if = "Option::is_none")]
141 pub pet: Option<HubPet>,
142 #[serde(default)]
143 pub url_scheme_overrides: Vec<String>,
144}
145
146#[derive(Debug, Clone, Serialize, Deserialize)]
147pub struct HubAppearance {
148 pub style_preset: String,
149 pub behavior_preset: String,
150}
151
152#[derive(Debug, Clone, Serialize, Deserialize)]
153pub struct HubVoice {
154 pub enabled: bool,
155}
156
157#[derive(Debug, Clone, Serialize, Deserialize)]
158pub struct HubPet {
159 pub enabled: bool,
160}
161
162#[derive(Debug, Clone, Serialize, Deserialize)]
165pub struct CommanderBlock {
166 pub chat_platforms: Vec<String>,
167 #[serde(default)]
168 pub workflows: Vec<CommanderWorkflowRef>,
169 #[serde(default)]
170 pub programs: Vec<CommanderProgramRef>,
171 #[serde(skip_serializing_if = "Option::is_none")]
172 pub jira: Option<CommanderJira>,
173 #[serde(skip_serializing_if = "Option::is_none")]
174 pub sub_agents: Option<CommanderSubAgents>,
175 #[serde(skip_serializing_if = "Option::is_none")]
176 pub schedule_defaults: Option<CommanderScheduleDefaults>,
177}
178
179#[derive(Debug, Clone, Serialize, Deserialize)]
180pub struct CommanderWorkflowRef {
181 pub name: String,
182 pub file: String,
183 #[serde(skip_serializing_if = "Option::is_none")]
184 pub schedule: Option<String>,
185}
186
187#[derive(Debug, Clone, Serialize, Deserialize)]
188pub struct CommanderProgramRef {
189 pub file: String,
190}
191
192#[derive(Debug, Clone, Serialize, Deserialize)]
193pub struct CommanderJira {
194 pub base_url: String,
195 pub secret: String,
196}
197
198#[derive(Debug, Clone, Serialize, Deserialize)]
199pub struct CommanderSubAgents {
200 pub max_concurrent: u32,
201}
202
203#[derive(Debug, Clone, Serialize, Deserialize)]
204pub struct CommanderScheduleDefaults {
205 pub timezone: String,
206}
207
208impl MuragentManifest {
211 pub fn requires_v3(&self) -> bool {
213 self.model_requirements.is_some()
214 }
215
216 pub fn schema_supported(&self) -> bool {
218 matches!(self.schema.as_str(), "mur-agent/2" | "mur-agent/3")
219 }
220
221 pub fn is_v2(&self) -> bool {
223 self.schema == "mur-agent/2"
224 }
225
226 pub fn validate_bundle_id(&self) -> Result<(), String> {
228 let expected = format!("run.mur.agent.{}", self.agent.slug);
229 if self.agent.bundle_id != expected {
230 return Err(format!(
231 "bundle_id '{}' does not match expected '{}'",
232 self.agent.bundle_id, expected
233 ));
234 }
235 Ok(())
236 }
237}
238
239#[cfg(test)]
240mod model_hint_tests {
241 use super::*;
242
243 #[test]
244 fn manifest_round_trips_without_model_hint() {
245 let yaml = "\
246schema: mur-agent/2
247exported_at: '2026-05-29T00:00:00Z'
248exporter: { mur_version: 1.0.0, tool: mur }
249agent: { slug: coach, display_name: Coach, bundle_id: run.mur.agent.coach, url_scheme: muragent-coach, original_uuid: u1 }
250required_surfaces: [hub]
251icon: {}
252";
253 let m: MuragentManifest = serde_yaml_ng::from_str(yaml).unwrap();
254 assert!(m.model_hint.is_none());
255 }
256
257 #[test]
258 fn model_hint_serializes_and_parses() {
259 let hint = ModelHint {
260 provider: "ollama".into(),
261 name: "llama3.2:3b".into(),
262 tier: ModelTier::Small,
263 min_ram_gb: 8,
264 local_capable: true,
265 };
266 let s = serde_yaml_ng::to_string(&hint).unwrap();
267 let back: ModelHint = serde_yaml_ng::from_str(&s).unwrap();
268 assert_eq!(hint, back);
269 assert!(s.contains("tier: small"));
270 }
271}
272
273#[cfg(test)]
274mod distribution_marker_tests {
275 use super::*;
276
277 #[test]
278 fn distribution_marker_roundtrips_and_defaults_none() {
279 let yaml = "\
280schema: mur-agent/2
281exported_at: '2026-05-29T00:00:00Z'
282exporter: { mur_version: 1.0.0, tool: mur }
283agent: { slug: coach, display_name: Coach, bundle_id: run.mur.agent.coach, url_scheme: muragent-coach, original_uuid: u1 }
284required_surfaces: [hub]
285icon: {}
286";
287 let m: MuragentManifest = serde_yaml_ng::from_str(yaml).expect("legacy yaml parses");
288 assert!(m.distribution.is_none());
289 let mut m2 = m.clone();
290 m2.distribution = Some("official".into());
291 let round: MuragentManifest =
292 serde_yaml_ng::from_str(&serde_yaml_ng::to_string(&m2).unwrap()).unwrap();
293 assert_eq!(round.distribution.as_deref(), Some("official"));
294 }
295}