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