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