Skip to main content

mur_common/muragent/
manifest.rs

1//! `.muragent` v2 manifest schema types.
2//!
3//! Schema version: `mur-agent/2`. No backwards compat with `mur-agent-package/1`.
4
5use serde::{Deserialize, Serialize};
6
7/// Top-level manifest as written to `manifest.yaml` inside a `.muragent`.
8#[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    /// Reserved for future specs; v1 must ignore.
27    #[serde(skip_serializing_if = "Option::is_none")]
28    pub deployment: Option<serde_json::Value>,
29    /// Reserved for future specs; v1 must ignore.
30    #[serde(skip_serializing_if = "Option::is_none")]
31    pub assignment: Option<serde_json::Value>,
32    /// Model backend hint for the recipient's first-run resolution (§7.1).
33    #[serde(default, skip_serializing_if = "Option::is_none")]
34    pub model_hint: Option<ModelHint>,
35    /// Signed minimum model capabilities resolved by compatible installers.
36    #[serde(default, skip_serializing_if = "Option::is_none")]
37    pub model_requirements: Option<ModelRequirements>,
38    /// `Some("official")` for agents published from the official catalog.
39    /// Tamper-evident via `predicate.manifest_sha256` in the DSSE-signed
40    /// statement (the canonical JSON derived from `manifest.yaml`), so
41    /// stripping it invalidates the package signature.
42    #[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/// Declares what kind of model the agent was authored against, so the
74/// recipient's first-run wizard can resolve a backend (no weights travel).
75/// See spec §7.1.
76#[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// ─── Hub-specific block ───
134
135#[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// ─── Commander-specific block ───
163
164#[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
208// ─── Validation helpers ───
209
210impl MuragentManifest {
211    /// Whether this manifest carries fields that require schema v3.
212    pub fn requires_v3(&self) -> bool {
213        self.model_requirements.is_some()
214    }
215
216    /// Whether this MUR build understands the declared schema.
217    pub fn schema_supported(&self) -> bool {
218        matches!(self.schema.as_str(), "mur-agent/2" | "mur-agent/3")
219    }
220
221    /// Schema version must be exactly `mur-agent/2`.
222    pub fn is_v2(&self) -> bool {
223        self.schema == "mur-agent/2"
224    }
225
226    /// Slug must match `run.mur.agent.<slug>` bundle ID pattern.
227    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}